准备工作
设计
设计Demo功能、表结构、后端接口、前端页面和交互。
-
功能。实现简单的购买商品的功能。用户购买商品时直接扣除账户中的金币,不涉及真正的支付,且金币只能为整数;购买成功后也不涉及备货发货收货等流程,直接默认用户购买成功就拿到货物了,订单状态已完成;没有用户登录注册功能,直接使用一个下拉列表进行用户切换,模拟多用户操作;在“我的订单”中,能看到当前用户的订单;商品信息包含:商品图片、标题、价格、销售数量;用户信息包含:用户名、手机号、用户地址、金币数量;订单信息包含:购买的商品信息、购买的商品数量、总价格、收货人、收货人手机号、收货地址(订单中的收货人信息直接使用相应的用户信息)。
-
表结构。用户和订单之间是一对多的关系,一个用户可能有多个订单,一个订单只能对应一个用户;订单和商品之间是多对多的关系,一个订单可能有多个商品,同一商品可能包含在多个订单中,因为订单和商品多对多的关系,所以需要创建一个关联表。在商品信息中有一个销售数量这种统计类型的字段,直接放在商品表中不合理,因为统计类型的数据的变动一般比商品信息的变动大很多,所以新写一个表,用于存放商品的销售数量。
-
后端接口。其实对于和用户相关的接口,都需要进行用户身份的验证,如果用户身份验证没有通过,则接口直接401,但是本文中的小demo非常简陋,没有用户登录注册流程,所以以下所有接口都没有进行用户验证。
-
前端页面和交互。 注意购物车部分的数据都是写在前端的数据,没有使用真正的接口请求。购物车部分只是为了让这个demo的购物流程稍微正常一点。
创建数据库
安装Node以及 安装MySQL。
根据设计好的表结构创建数据库,并且添加一些必要的数据。在命令行中依次输入以下指令 (为了方便,在命令行输入sql语句的时候使用小写字母,为了直观,在代码中使用大写字母):
-
mysql -u root -p
,以root的身份使用数据库。 -
create database shopping
,创建一个名为shopping的数据库。 -
use shopping
,使用创建好的shopping数据库。 -
创建商品表
create table commodities ( commodity_id int unsigned not null auto_increment primary key, title char(100), price int unsigned, image_url char(100), created_time datetime );
-
创建用户表:
create table users ( user_id int unsigned not null auto_increment primary key, name char(50) not null, telephone_number char(20) not null, address char(50) not null, coins_quantity int unsigned, created_time datetime );
-
创建订单表:
create table orders ( order_id int unsigned not null auto_increment primary key, user_id int unsigned not null, amount int unsigned, created_time datetime );
-
创建订单和商品的关联表:
create table orders_commodities ( order_id int unsigned not null, commodity_id int unsigned not null, commodities_quantity int unsigned, primary key (order_id, commodity_id) );
订单和商品的关联表有两个主键,订单id和商品id。
-
创建商品统计数据的表:
create table commodities_statistics ( commodity_id int unsigned not null primary key, sales_volume int unsigned );
因为省略了用户注册、商品创建等功能,所以一开始就手动添加好用户表users和商品表commodities,以及commodities_statistics表中的数据。这里只简单说明下使用命令行工具如何往表中插入数据,剩下的用户数据和商品数据使用Navicat Premium 添加。向users表中插入一条数据:
insert into users values (NULL, '小起', '13012345678', '某国某省某市', 100, '2020-01-30 00:00:00');
-
创建一个管理员用户,用于后续Node服务器连接MySQL数据库。
create user shopping_manager identified by 'secret';
给用户添加对数据库进行增删改查的权限:
grant insert, delete, update, select on shopping.* to shopping_manager;
允许用户改变现存表的结构:
alter user 'shopping_manager' identified with mysql_native_password by 'secret';
在不重启服务的情况下使对用户权限的修改生效:
flush privileges;
后端实现
搭建一个简单的服务器
初始化项目: npm init -y
。
创建需要的文件和文件夹:
touch server.js serverRequestHandlers.js && mkdir resources
server.js是服务器的入口文件,serverRequestHandlers.js是对具体请求的接口的处理方法。resources文件夹中存放的是商品图片。 server.js中有几个主要的方法:
对请求资源的处理
function requestResource (req, res, parsedUrl) {
let pathname = parsedUrl.pathname;
let filePath = pathname.substr(1);
filePath = filePath === '' ? 'index.html' : filePath;
let stream = fs.createReadStream(filePath);
let responseData = [];
if (stream) {
stream.on('data', (chunk) => {
responseData.push(chunk);
});
stream.on('end', () => {
let total = Buffer.concat(responseData);
res.writeHead(200);
res.write(total);
res.end();
});
}
}
根据请求url,读取相应位置的文件二进制数据并返回给前端。
对GET请求的处理
function handleGetRequest (req, res, parsedUrl) {
const { query, pathname } = parsedUrl;
let parsedData = {};
if (query) {
let queryString = decodeURIComponent(query);
let items = queryString.split('&');
for (let item of items) {
let arr = item.split('=');
let key = arr[0];
let value = JSON.parse(arr[1]);
parsedData[key] = value;
}
}
requestHandlers({ api: pathname, req, res, params: parsedData, createConnection });
}
将请求的url中带的数据整理为一个对象,作为参数传递到requestHandlers中,requestHandlers会对不同的请求url进行不同的处理,处理完成后通过res.end(JSON.stringify(results));
这样的代码将结果返回给前端。
对POST请求的处理
function handlePostRequest (req, res, parsedUrl) {
const { pathname } = parsedUrl;
req.on('error', (err) => {
res.end(`请求失败:${err}`);
});
let body = [];
req.on('data', (chunk) => {
body.push(chunk);
});
req.on('end', () => {
const bodyBuffer = Buffer.concat(body);
const bodyString = bodyBuffer.toString();
let parsedData = JSON.parse(bodyString);
requestHandlers({ api: pathname, req, res, params: parsedData, createConnection });
});
}
POST请求中携带的请求数据是二进制数据,将请求数据也就是body拿到后,将body转换为字符串再通过JSON解析,整理出想要的请求参数。和GET请求一样,将整理出的对象作为参数传递到requestHandlers中,requestHandlers会对不同的请求url进行不同的处理,处理完成后通过res.end(JSON.stringify(results));
这样的代码将结果返回给前端。
连接数据库
安装sql包
npm i sql --save
在server.js文件中添加以下代码:
const mysql = require('mysql');
const createConnection = () => {
return mysql.createConnection({
host: 'localhost',
user: 'shopping_manager',
password : 'secret',
database : 'shopping',
multipleStatements: true, // 允许一次使用多个查询语句,这在定义sql变量@inserted_order_id的时候需要用到
});
};
createConnection是用于创建一个连接实例的,每次连接时都创建一个新的实例,数据库操作完成后使用connection实例的end方法断开连接。
各个接口的实现
对各个接口请求的具体处理放在serverRequestHandlers.js中。
获取用户列表
function getUsersList (paramsObj) {
const { createConnection, res } = paramsObj;
const connection = createConnection();
connection.connect();
connection.query('SELECT * FROM users', function (error, results, fields) {
if (error) throw error;
res.writeHead(200, {'Content-Type': 'text/palin; charset=utf-8'});
res.end(JSON.stringify(results));
});
connection.end();
}
将数据库中users的所有数据返回给前端。
获取商品列表
获取商品列表和获取用户列表的方式一样,只是sql语句不同。
const sqlCommand = `
SELECT *
FROM commodities
LEFT JOIN commodities_statistics
USING (commodity_id)
`;
connection.query(sqlCommand, function (error, results
...
commodities表和commodities_statistics表中都有commodity_id列,根据commodity_id列的值将commodities_statistics中commodities表没有的列合并到结果中。因为返回的信息需要包含商品的基础信息以及商品的销售数量。
获取订单列表
获取订单列表和获取用户列表的方式一样,只是sql语句不同,并且sql不能直接拿到前端需要的数据,还需要经过整理。
const sqlCommand = `
SELECT o.order_id, o.amount, o.user_id, o.created_time,
oc.commodity_id, oc.commodities_quantity,
c.title, c.price, c.image_url
FROM orders AS o
INNER JOIN orders_commodities AS oc
ON o.order_id = oc.order_id
INNER JOIN commodities AS c
ON oc.commodity_id = c.commodity_id
WHERE o.user_id = ${user_id}
`;
将commodities表和orders_commodities合并在一起,就得到了一张包含商品信息的表1,将orders表和orders_commodities合并在一起,就得到了包含订单信息的表2,将三张表结合在一起,就得到了同时包含商品信息,订单信息的多对多的表3。user_id是从请求中解析出的user_id的值,从表3再筛选出订单的user_id等于解析出的user_id的数据。这个sql语句最终得到的结果是一个可能有多个相同的order_id和多个相同的commodity_id、user_id唯一、包含订单信息和商品信息的表4。关于join语句可以参考mysql join。 将表4的数据整理为前端所需的数据并返给前端:
// 根据order_id将数据整理成订单列表
let ordersList = [];
let ordersInfo = {};
results.forEach((item) => {
const { order_id, amount, commodity_id, commodities_quantity, title, price, image_url, created_time } = item;
if (!ordersInfo[order_id]) {
ordersInfo[order_id] = {
order_id, // 订单id
amount, // 订单总价
commodities: [], // 商品列表
created_time, // 创建时间
};
}
const commodity = {
commodity_id,
commodities_quantity,
title,
price,
image_url
};
ordersInfo[order_id].commodities.push(commodity);
});
for (let key in ordersInfo) {
ordersList.push(ordersInfo[key]);
}
res.writeHead(200, {'Content-Type': 'text/palin; charset=utf-8'});
res.end(JSON.stringify(ordersList));
创建订单
function createOrder (paramsObj) {
const { params, createConnection, res } = paramsObj;
const connection = createConnection();
let { user_id, amount, commodities } = params;
let changeCommoditiesQuantity = ''; // 修改已售商品的数量
let addOrderCommodity = ''; // 添加订单和商品之间的关系
const commoditiesIds = commodities.map((item) => {
const { commodity_id, commodities_quantity } = item;
changeCommoditiesQuantity += ` WHEN ${commodity_id} THEN sales_volume + ${commodities_quantity} `;
addOrderCommodity += ` INSERT INTO orders_commodities VALUES (@inserted_order_id, ${commodity_id}, ${commodities_quantity}); `;
return item.commodity_id;
});
const commoditiesIdsString = commoditiesIds.join(', ');
let createdTime = new Date();
createdTime = formatDateTime(createdTime);
let sqlCommand = `
INSERT INTO orders (user_id, amount, created_time) VALUES (${user_id}, ${amount}, '${createdTime}');
SET @inserted_order_id = 0;
${addOrderCommodity}
UPDATE commodities_statistics
SET sales_volume = CASE commodity_id
${changeCommoditiesQuantity}
END
WHERE commodity_id IN (${commoditiesIdsString});
UPDATE users
SET coins_quantity = coins_quantity - ${amount}
WHERE user_id = ${user_id};
`;
connection.connect();
connection.query(sqlCommand, function (error, results, fields) {
if (error) throw error;
res.writeHead(200, {'Content-Type': 'text/palin; charset=utf-8'});
res.end(JSON.stringify({ code: 0 })); // 直接使用code为0表示创建成功
});
connection.end();
}
- 首先用请求中拿到的数据往orders表中插入一条数据。为了通过
0
拿到新插入行的order_id,并将它的值存储在一个变量中以供后面使用,需要使用语句SET @inserted_order_id = 0;
,所以在用createConnection创建connection实例的时候,需要添加上属性:multipleStatements: true
,允许一次使用多个查询语句。 - 修改commodities_statistics表的数据
一个订单中可能包含多个商品,每个商品有自己的数量,这个时候需要对不同的商品的销售数量进行不同的修改。比如订单中包含一件commodity_id为1的商品A,两件commodity_id为2的商品B,那么商品A的数量需要加1,商品B的数量需要加2,需要执行的指令为:
按照这个规律整理出修改商品销售数量的指令。UPDATE commodities_statistics SET sales_volume = CASE commodity_id WHEN 1 THEN sales_volume + 1 WHEN 2 THEN sales_volume + 2 END WHERE commodity_id IN (1, 2);
- 修改users表的数据 要根据订单总共的价格,扣除用户的金币,即users表中对应用户的金币数量减小相应的值。
前端实现
静态页面
创建文件touch index.css index.html index.js
之后,根据上文的设计图,创建出静态的页面。
定义了全局变量appData来存储全局数据,其中commoditiesList存放的是所有商品的信息,存放商品列表信息是因为购物车页面是假页面,没有使用接口请求但又需要用到商品信息。
以切换标签为例说明一下静态页面的创建。这个小demo主要分为3个页面,商店、购物车和我的订单。以绝对定位的方式创建了3个div,这3个div的z-index都为-2,要展示某个页面,就将对应的z-index设置为-1即可。
function changTab (tabElement, tabName) {
const nextTag = tabElement || document.getElementById(tabName);
const previousPart = appData.currentPart;
if (previousPart && previousPart === tabName) return;
const previousPartTabElement = document.getElementById(previousPart);
previousPartTabElement.className = previousPartTabElement.className.slice(0, -9);
appData.currentPart = tabName;
nextTag.className += ' selected';
changeContent(previousPart, tabName);
}
// 根据不同的标签切换内容
function changeContent (previousPart, nextPart) {
const previousContentElement = document.getElementById(previousPart + 'Content');
const nextContentElement = document.getElementById(nextPart + 'Content');
previousContentElement.className = previousContentElement.className.slice(0, -8);
nextContentElement.className += ' current';
switch (nextPart) {
case 'shop':
displayShop();
break;
case 'cart':
displayCart();
break;
case 'orders':
displayOrders();
break;
}
}
appData中的currentPart用于存放表示不同页面的字段:shop、cart、orders。这个字段的值和html中相应元素的dataset对应。
<div class="tabs">
<div data-tab="shop" id="shop" class="tab">商店</div>
<div data-tab="cart" id="cart" class="tab">购物车</div>
<div data-tab="orders" id="orders" class="tab">我的订单</div>
</div>
初始情况下默认设置当前页面是商店页面,后续切换的时候,将前一个页面对应的标签元素和内容元素的选中类名去掉,给即将跳转的页面对应的元素加上选中类名。
请求接口数据组织页面
根据已经创建好的静态页面,拿请求接口得到的数据创建元素,组织页面。 比如获取用户信息并创建下拉组件部分代码:
async function getUsesInfo () {
const { requstApis } = appData;
const users = await request({ url: requstApis.getUsersInfo });
appData.usersInfo = users;
appData.currentUser = users[0];
const selectUserElement = document.getElementById('selectUser');
const fragment = document.createDocumentFragment();
users.forEach((user) => {
const { user_id, name } = user;
const option = document.createElement('option');
option.value = user_id;
option.textContent = name;
fragment.appendChild(option)
});
selectUserElement.append(fragment);
appData.currentUser = users[0];
selectUserElement.value = users[0].user_id;
selectUserElement.onchange = (event) => {
appData.currentUser = appData.usersInfo.find((item) => item.user_id === parseInt(event.target.value));
};
}
使用createDocumentFragment创建的fragment,防止频繁向页面中append元素,减少重排和重绘。 request是封装的一个简陋的请求方法:
function request ({url, method, params}) {
if (!method || method === 'GET' ) { // GET请求
let queryString = '';
if (params) {
for (let key in params) {
queryString += `${key}=${params[key]}&`
}
}
queryString = queryString.slice(0, -1);
const requestUrl = `${url}?${queryString}`;
return fetch(requestUrl)
.then(function(response) {
return response.json();
})
}
if (method === 'POST') {
return fetch(url, {
method: 'POST',
body: JSON.stringify(params),
headers: {
'content-type': 'application/json'
},
})
.then(function(response) {
return response.json();
})
}
}
其他
源码地址。最终实现效果: