文档标识:[C-180826-M-180908]
Github来源:ryan4g原创内容,转载请先告知
项目背景
看到登录时可以选择微信扫描登录,想了想其中的原理好像并不复杂。正好一直在学习Node.js,想着靠自己现有的水平,实现这个PC端生成二维码,通过手机扫描二维码,实现PC端触发登录的例子。
技能准备
本人的Node.js水平刚入门,所用到的技能并不深入,能够实现基本的路由和二维码生成就刚好能满足这个项目要求。项目用到了以下npm库:
- express (用来实现路由处理)
- qr-image(用来生成带跳转网址的二维码)
- uuid-js (用来生成唯一UID,以区分响应不同窗口的二维码)
- Node自带的一些库,不需要再npm 安装:fs (用于读取本地文件)、http (网络库)、url (网址解析)、path(本地路径解析)
项目架构
在开始项目编程之前,先把想要实现的东西过一遍,形成了以下脑图:
文件结构和使用Express 应用生成器生成的差不多,但对于本项目来说,还是有些多余了,对文件结构进行了一些调整:
.
├── index.js
├── bin
│ └── data.json
├── package.json
├── public
│ ├── css
│ └── style.css
│ ├── images
│ └── js
│ ├── index.js
│ └── jquery-3.3.1.min.js
└── views
├── confirm.html
└── main.html
从文件结构图上可以看到,用到了jquery,这里主要是有一个AJAX操作,用到了jquery.ajax比较方便实现。然后,views里面的是页面模板,express支持的是ejs和jade,由于目前还没实际使用过,所以用了比较笨的方法。(后记:实际上可以用ejs模板,然后可以使用express.render,这是比较正常的做法。)
项目编程
开始之前
在开始编程之前,由于这篇文章可能会帮助到一部分比我都新的入门者,这里还是要提及一些工具的安装:
- 首先,需要确认自己的node.js环境搭建完毕,具体安装教程请参考这里——Node.js安装
- 然后,在国内的话,由于连接npm源的速度较慢,最好把npm源换成国内的cnpm,更换方法请参考这里——淘宝NPM镜像
- 最后,项目中用到了jquery,可在jquery官网下载
npm模块安装
在了解以上情况后,现在把所需要用到的模块安装进来,这里假设之前没有全局安装过,仅对本项目安装。
- 新建一个文件夹用于存放项目,这里命名为
qrcodelogindemo
,使用以下命令初始化项目:
npm init
跳出的提示按回车默认即可,或者根据自己想法写点什么。主要是为了生成package.json
文件。
- 按照前面技能准备里提及的模块,现在可以一个个安装,使用以下命令进行安装:
cnpm install express qr-image uuid-js --save
- 文档结构按照项目架构里手动新建文件夹(文件可以暂时不用管),或者使用express 应用生成器自动生成,然后删除一些不需要的文件夹,自动生成需要使用以下命令:
cnpm install express-generator -g
# 使用自动生成的话,需要在项目外部执行,或者手动移出内容
express qrcodelogindemo
PC端页面构成
页面构思
在安装完毕所有模块后,对PC端需要的展现的页面进行构思,要实现的效果,就和微信授权登录页面一样:
-
显示一个二维码图片
-
扫描后,页面会显示“成功扫描,请确认登录”
-
在手机端如果点击取消,页面会显示“已取消登录,可再次扫描”
- 在手机端如果点击确认,页面跳转。(这里是跳回二维码页面重新生成,按的操作就是跳回主页,原理一样)
实现难点
在这个页面中,难点是:手机端扫描后,如何通知PC端页面进行状态处理? 一般来说,在前后端的交互过程,是由前端主动向后端进行数据请求,这里可以使用的技术有:
- AJAX
- Websocket
然而在本项目里,要在手机端确认登录后,后端通知PC端网页进行状态处理,明显和一般情况不一样,这里更像是后端主动向前端发送数据。Websocket不太适合用在这个项目,更适合AJAX请求,那么这里,就涉及到一个概念:反向AJAX。实现这个技术可以有以下几种:
- 间隔轮询(隔一段时间发送一个AJAX请求)
- 长轮询(一个AJAX请求不间断,即成功返回又立即请求)
- 长连接(一个AJAX请求后不结束,后端不断返回数据)
受限于本人的水平,反向AJAX的概念并没有找到更多的资料,其中第1、2种都比较容易理解,第3种如何保持一个AJAX请求不结束的方法暂时还没有思路。
那么来看看微信授权的二维码页面是采用哪种方式,打开google开发者工具,切换到Network页面,可以看到请求队列:
可以发现,它使用的是长轮询,维持了一个27s左右的AJAX请求。查看网页的javascript脚本,可以发现这么一段代码:
复制下来并格式化后:
这里可以发现,在页面的javascript脚本中,使用了AJAX请求并根据服务端返回不同的state代码,对该请求进行了重新延时发送请求。那么基于这点,可以在服务端也提供一个返回状态的接口用于响应AJAX请求。
解决了这个问题,就可以根据不同的状态进行页面更改,例如:已扫描、取消扫描、确认登录的状态。
页面编码
现在主要是页面的AJAX请求写法,这里使用了Jquery的Ajax方法,直接向后端发送请求,并提供了状态响应。
以下是main.html页面中的javascript代码:
let scanned = function(){
jQuery('.qrcode-bottom p').text("已成功扫描,等待手机确认登录");
};
let verified = function(username) {
jQuery('.qrcode-bottom p').text('欢迎您:' + username + ',您已成功登录');
// 成功登录后,2秒后跳转,此处重新加载
setTimeout(()=>{
window.location.reload();
}, 2000);
};
let canceled = function(){
jQuery('.qrcode-bottom p').text("已取消登录,请重新扫描确认");
};
let longpolling = function (){
jQuery.ajax({
url: "/verified?uid=SESSION_UID",
dataType: "json",
cache: false,
timeout: 30000,
success: function(respond){
let cmdCode = respond.cmd;
let delayTime = 3000;
if(cmdCode === "scanned"){
scanned();
delayTime = 1000;
setTimeout(longpolling, delayTime);
}
else if (cmdCode === "verified"){
verified(respond.user);
}
else if (cmdCode === "canceled"){
canceled();
setTimeout(longpolling, delayTime);
}
else{
setTimeout(longpolling, delayTime);
}
},
error: function(){
setTimeout(longpolling, 5000);
}
});
};
setTimeout(longpolling, 100);
注意:这里有个SESSION_ID的预置字符串,这里后端需要进行处理。当然,如果使用模板的话,可以使用ejs模板的变量赋值。
手机端页面构成
页面构思
在解决PC端的网页后,现在进行手机端的页面构思,需要使用的功能:
- 显示确认登录、取消登录的界面
- 取消登录、确认登录的状态能够发送到服务端
- 确认登录后进行成功跳转
- 取消登录后退出微信内置浏览器
实现难点
没有难点,只要按下按钮时能够直接使用href调用服务端的接口,或者通过DOM的click事件,使用AJAX与服务端交互,把UID和状态传入即可。
页面编码
这里确认按钮可以直接进行href跳转,也可使用click事件处理AJAX请求,这里使用了href跳转:
确认登录
取消登录
然后,由于取消登录按钮需要响应微信内置浏览器的退出功能,则只能使用AJAX的方式,调用成功后直接调用浏览器的退出功能,以下是取消登录按钮的javascript代码:
jQuery('#cancelBtn').click(()=>{
jQuery.ajax({
url: "/confirmed?uid=SESSION_UID&operate=cancel",
dataType: "html",
success: function (){
// 微信内置浏览器退出
WeixinJSBridge.call('closeWindow');
}
});
});
数据存储方式
JSON文件读写函数
由于这个项目需要的数据存储功能非常简单,在此使用一个JSON文件进行存储即可,而且使用fs类库进行读写也非常方便。
在node服务端定义了两个函数,在进行状态更改时,可直接调用进行JSON文件的读写。以下是node端的代码:
/*
* Description: 写入JSON文件
* Params:
* fileName - JSON文件所在路径
* uid - 生成的uid
* writeData - 需要写入的JSON格式数据
*
*/
setJSONValue = function(fileName, uid, writeData){
let data = fs.readFileSync(fileName);
let users = JSON.parse(data.toString());
let addFlag = true;
let delFlag = (writeData === null);
for (let i = 0; i < users.data.length; i++){
if (users.data[i].uid === uid){
addFlag = false;
if (delFlag){
users.data.splice(i,1);
}
else{
users.data[i].status = writeData.status;
console.log("writeJSON: " + JSON.stringify(users.data[i]) + " modified.");
}
}
}
if(addFlag){
users.data.push(writeData);
console.log("writeJSON: " + JSON.stringify(writeData) + " inserted.");
}
// 同步写入文件
let writeJSON = JSON.stringify(users);
fs.writeFileSync(fileName, writeJSON);
}
/*
* Description: 读取JSON文件(要返回数据,选择同步读取)
* Params:
* fileName - JSON文件所在路径
* uid - 生成的uid
*
*/
getJSONValue = function(fileName, uid){
let readData = null;
// 同步读取文件
let data = fs.readFileSync(fileName);
let users = JSON.parse(data.toString());
for (let i = 0; i < users.data.length; i++){
if (users.data[i].uid === uid){
readData = JSON.stringify(users.data[i]);
break;
}
}
return readData;
}
JSON文件结构
JSON文件主要写入以下信息,用于解析和数据交互,以下为JSON格式的文件内容(有数据的情况下):
{"data":[
{"uid":"95c0e9ba-ae99-43c7-ab6a-1e357cc24b5f","status":"scanned","name":"USER"}
]}
如果是还没有的情况下,需要预先创建一个JSON文件,内容包含一个data键的空数组即可:
{"data":[]}
服务端构思
在经过以上PC页面和手机页面的分析,在服务端需要提供相应的接口,这里一共需要提供的功能接口如下:
- 返回QRCode 图片的接口
- 响应扫描动作的接口
- 响应查询状态的接口
- 响应确认登录的接口
QRCode图片接口编码
在生成二维码时,考虑到微信扫描二维码后,会根据内容进行处理,如果是一个网址则会直接使用内置浏览器进行跳转。那么生成二维码的时候,确认了需要包含的信息是:一个包含UID的网址。
// 生成二维码图片并显示
app.get('/qrcode', function (req, res, next) {
let uid = url.parse(req.url, true).query.uid;
try {
if (typeof(uid) !== "undefined"){
// 写入二维码内的网址,微信扫描后自动跳转
let jumpURL = "http://" + localNetAddress + "/scanned?uid=" + uid;
// 生成二维码(size:图片大小, margin: 边框留白)
var img = qrcode.image(jumpURL, {size :6, margin: 2});
res.writeHead(200, {'Content-Type': 'image/png'});
img.pipe(res);
}
else{
res.writeHead(414, {'Content-Type': 'text/html'});
res.end('414 Request-URI Too Large
');
}
} catch (e) {
res.writeHead(414, {'Content-Type': 'text/html'});
res.end('414 Request-URI Too Large
');
}
});
响应扫描动作接口
在手机进行扫描二维码后,会直接跳转二维码内包含的网址,这时候调用该接口。需要实现手机确认界面的显示以及将该UID的状态记录到JSON文件中,以便PC端网页做出相应改变。
// 显示手机扫描后的确认界面
app.get('/scanned', function(req, res){
let uid = url.parse(req.url, true).query.uid;
if (typeof(uid) !== "undefined"){
generateHTML(uid, req, res, path.join(__dirname, '/views/confirm.html'));
console.log("uid: '" + uid + "' scanned.");
// 获取JSON文件内对应uid的数据,更改其数据状态
let jsonData = getJSONValue(path.join(__dirname, '/bin/data.json'), uid);
if(jsonData === null){
jsonData = {
uid: uid,
status: "scanned",
name: "USER"
}
}
else{
jsonData = JSON.parse(jsonData);
jsonData.status = "scanned";
}
// 写入JSON文件
setJSONValue(path.join(__dirname, '/bin/data.json'), uid, jsonData);
}
else{
res.writeHead(414, {'Content-Type': 'text/html'});
res.end('414 Request-URI Too Large
');
}
});
响应查询状态接口
为了响应PC端网页不断的AJAX请求,在该接口需实现从JSON文件中获取对应UID的实际状态,用于PC端网页响应操作:
// 响应主页不断的AJAX请求
app.get('/verified', function(req, res){
let uid = url.parse(req.url, true).query.uid;
// normal - 没有任何触发
// scanned - 已扫描
// canceled - 已取消
// verified - 已验证
let dataStatus = {
cmd: "normal",
user: ""
}
console.log("uid: '" + uid + "' query ...");
if (typeof(uid) !== "undefined"){
let userData = getJSONValue(
path.join(__dirname, '/bin/data.json'),
uid
);
// 返回JSON数据用于首页AJAX操作
if(userData !== null){
userData = JSON.parse(userData);
dataStatus.cmd = userData.status;
dataStatus.user = userData.name;
}
}
// 这里如果设置延时返回,那所有AJAX响应都会等待,微信延时27秒左右的时间内,仍能够做出及时response
// 目前是无等待响应,这里有待改进
//setTimeout(function(){
res.end(JSON.stringify(dataStatus));
//}, 10000);
});
响应确认登录接口
手机端页面显示两个操作按钮,一个确认登录,一个取消登录。点击取消登录按钮关闭微信内置浏览器,确认登录则需通知服务端,进行页面跳转等操作:
// 在确认界面操作的响应
app.get('/confirmed', function(req, res){
let uid = url.parse(req.url, true).query.uid;
let operate = url.parse(req.url, true).query.operate;
if (typeof(uid) !== "undefined"){
console.log("uid: '" + uid + "' " + operate);
let jsonData = getJSONValue(path.join(__dirname, '/bin/data.json'), uid);
let status = (operate === "confirm") ? "verified" : "canceled";
if(jsonData === null){
jsonData = {
uid: uid,
status: status,
name: "USER"
}
}
else{
jsonData = JSON.parse(jsonData);
jsonData.status = status;
}
setJSONValue(path.join(__dirname, '/bin/data.json'), uid, jsonData);
if (status === "verified"){
res.writeHead(200, {'Content-Type': 'text/html'});
res.end('Verified!
');
}
else{
res.writeHead(200, {'Content-Type': 'text/html'});
res.end('Canceled!
');
}
}
else{
res.writeHead(414, {'Content-Type': 'text/html'});
res.end('414 Request-URI Too Large
');
}
});
文件替换网页SESSION_ID
在项目前面阶段,提到了有一种原始的方法向网页模板传输变量,那就是用文件读写的方式,进行对应字符串替换。这里使用了SESSION_ID作为占位符,在服务端返回模板渲染时,替换为对应的UID,实现数据传递。(当然,会使用jade或ejs模板也可以直接使用模板数据传递)为了方便,这里将模板读写统一放在一个函数里:
/*
* Description: 读取网页文件,用于替换关键字,相当于简易模板
* Params:
* sessionID - 生成的uid
* req - 网页请求
* res - 网页应答
* fileName - 网页文件所在路径
*/
generateHTML = function(sessionID, req, res, fileName){
fs.readFile(fileName, 'UTF-8', function(err, data) {
if(!err){
data = data.replace(/SESSION_UID/g, sessionID);
res.writeHead(200, {
'Content-Type' : 'text/html; charset=UTF-8'
});
res.end(data);
}
else{
console.log(err);
res.writeHead(404, {
'Content-Type' : 'text/html; charset=UTF-8'
});
res.end();
}
});
};
项目结果
运行方式
在所有工作准备完毕后,现在来验证一下,注意,手机和服务端需要在一个局域网内,否则受扫描后无法跳转,同时根据实际情况,修改一下localNetAddress
变量,以便IP正确访问。
如果使用VS Code的话,直接Debug运行;或者在命令行中,运行cnpm start
或node index.js
。在浏览器中输入: http://localhost:3000
运行截图
PC端主页
PC端被扫描后
手机端确认页
手机端取消后,PC端状态
手机端确认后,PC端生成新的二维码
后记
项目从构思到实现总共花了3天时间,这个对于刚入门node的菜鸟水平中规中矩了,在实现的过程中加深了自身对node的使用及理解。虽然项目中存在不少相当拙劣的实现技术实现方式,但这也是在不了解更高级技术的前提下的“曲线救国”,在提升技术能力后可以进行完善。
项目源码发布在:github.com/ryan4g ,由于本人水平有限,若文中存在明显技术错误,欢迎指正,避免误导他人。
如认为本篇文章,对你初学node起到了一定的启发作用,欢迎前往本人github主页对该项目进行star,谢谢。