2023年10月

一、背景

锦礼平台,作为一家企业级B2B2C电商平台,同时服务于企业客户和企业员工,因此需要遵循企业客户的政策规范,确保商城内商品符合规定,并提升员工购物体验。然而,这种独特的运营模式导致锦礼平台上商品的可见不可售问题较为突出,对最终消费者的购物体验和平台的产品和业务产生了较大的负面影响。

二、解决方案

如题,之所以说是小技巧,是因为我们并没有使用一些高精的技术,只是把多种成熟技术结合加入一些算法而已。

以下是我们经历的3个版本的方案迭代,也代表着一个技术人从技术思维到业务思维的转变

版本1.0
:我们尝试在不可售商品上增加一个遮罩,标注其不可售的原因,以防止客户误操作。然而,这种方法并未完全解决问题,因为消费者可能仍然对某些商品为何不可售(例如为何在锦礼平台无法购买黄金,或为何看到的商品被列入黑名单)感到困惑。

版本2.0
:我们努力提升搜索的效率,加快不可售商品出库的速度,并优化商品同步的机制,以降低不可售商品的出现频率。然而,随着锦礼平台不可售规则的扩展(如定价规则和价格倒挂限制等),这种定制化的方式对搜索团队来说过于复杂。

版本3.0
:随着我们对消费者需求的深入理解,我们逐渐意识到,虽然我们前期的手段降低了不可售的商品的即使平台上只出现一个不可售商品,也可能会对消费者的购物体验造成损失。因此,我们需要运用技术手段将这些商品“隐藏”起来。

方案总览

图1 二次过滤工具

方案预演

在进行到3.0的时候,我们首先推演了以下两种方案:

  1. 前端预加载
    :每次前端发出请求时,会按照每页展示的数量向后端请求数据,并在接收到后端返回的结果后,根据既定的规则进行数据过滤。如果经过过滤的数据数量不足,则会继续向后端请求下一页的数据。
  2. 后端轮询
    :后端会不断地向下游接口发出请求,每次请求都会递增页码,直到获取到符合条件的数据结果为止。

然而,上述技术方案的缺点也是显而易见的:

  1. 前端发起的请求并进行过滤的操作,会导致前端业务逻辑的冗余,这与前端视图层的定位不符。
  2. 如果前端发起的请求没有收到数据返回,它会尝试重新请求。如果多次请求都存在数据残缺的问题,这可能会导致前端瀑布流的卡顿,表现为加载速度时快时慢,给消费者造成网络卡顿的错觉。
  3. 前端频繁地发起无效请求,会对网络流量产生不必要的消耗。
  4. 后端请求每次都是无状态的,如果处理不当,很容易造成数据的重复获取或者页码的混乱。
  5. 后端请求轮询的深度无法动态设置,如果设置不当,很容易造成接口超时。

针对前端预加载可能导致前端业务逻辑复杂化、引入后端轮询可能出现数据同步问题,我们进行了一系列的优化。

我们引入了寄存器和过滤器两个重要组件,对原始请求进行合理的调度和处理。

首先,寄存器用于临时存储商品数据,这样前端每次只需要从前端发送请求到寄存器,就可以获取到最新的商品数据,避免了后端轮询的重复请求和超时问题。

其次,过滤器则用于对商品数据进行筛选和处理。在获取到最新的商品数据后,过滤器会根据一定的规则对数据进行筛选,将符合政策规范和用户需求的商品数据筛选出来,并将其展示给用户。同时,对于不符合政策规范或存在违规商品的商品数据,过滤器将其隐藏或标记为不可售,以防止用户误操作购买违规商品。

通过这样的优化,我们既避免了前端业务逻辑复杂化的问题,也解决了后端轮询可能出现的数据同步问题。同时,改造后的接口仍然是一个普通的REST接口,前端和后端都可以轻松理解和使用。

图2 过滤工具的组成

以下是各个环节的职能介绍

原始请求(orgin request)

常见的列表请求有以下三种:

1、第三方提供的查询服务,RPC分页请求,比如搜索商品列表RPC接口、推荐商品列表RPC接口

2、数据库查询,mybatis分页查询,可以利用自增主键id,做后续请求

3、ES搜索查询,ES分页查询,滚动查询

入参:PageParams
, 标准分页请求参数,T为真实的分页请求对象

出参:PageResult
,标准分页返回参数,R为真实分页请求返回对象

SDK的实现基于泛型开发,调用方需要按照规范自定义实现该方法

过滤器( customer filter )

入参:List< R > sourceData,原始的返回结果列表

出参:List< R > targetData,筛选过滤后的结果列表

SDK的实现基于泛型开发,调用方需要按照规范自定义实现该方法

寄存器
(storage register )

通过缓存中间件实现,临时寄存查询结果,前端请求过来后优先从寄存器获取。将请求参数通过算法压缩,保证相同的请求参数,可以得到相同的值,来确定是否是同一查询条件的请求。

寄存器的key结构如下:

scroll_id : pin&actiivtyCode&uid&查询入参 MD5压缩算法,

另,查询入参需要排除掉页码以及动态变化的参数

存储内容:缓存补齐分页后剩余的数据快照、前端请求页码、实际后端请求页码

协调器
( coordinate )

协调器实现数据的补充和寄存器数据快照的查询、存入和取出,以及分页相关数据的更新

1、协调器根据原始前端请求,后端实际请求扩大步长,进行后续数据的拉取

2、前后端固定步长进行请求,比如前端每次请求10条,后端每次请求20条,步长比,可以根据实际情况动态调整

3、请求深度和前后端步长比通过ducc进行控制。 例如:前端每页请求10条数据,步长比为3,则后端每次请求每页30条数据;请求深度为2,则如果请求两次如果仍不符合要求则强制停止。客户维度定制请求参数

{
 "DEFAULT": {
 "deep": 2,
 "multiple": 3
 },
 "营销测试2": {
 "deep": 2,
 "multiple": 5
 },
 "呼铁福利商城采购账号": {
 "deep": 2,
 "multiple": 4
 }
}

系统原理

图3 数据二次过滤流程图

我们将数据二次过滤拆分为以下四个阶段:

Query阶段:

接收前端请求,根据寄存器中数据的大小,决定后端接口请求,后端按前端N倍步长进行RPC请求,将命中的结果,在缓存中创建一个优先队列快照,并通过scroll_id(C维度的缓存key)指向它,lastPageNo 指向上次访问的页码,realPageNo指向真实访问的页码

Filter阶段:

读取过滤规则,进行数据过滤的实现,常用的过滤规则,比如:不可售、无货、价格高于市场价等,不符合规则的数据直接移除掉

Cache阶段:

• 如果过滤后数据小于前端请求步长,则继续进入Query阶段,进行优先队列快照数据补充

• 如果过滤后数据大于等于前端请求步长,则直接返回pageSize的数据列表,剩余数据放入优先队列快照中

• 后端请求深度暂设置为2次,如果两次后端请求,过滤后的数据仍不满足前端请求步长,则不再继续请求

• 如果后端请求多次返回失败,则及时熔断,返回寄存器内剩余数据,且返回结果标识非最后一页

Fetch阶段

通过以上两个阶段,我们可以获取到符合前端请求步长的数据集,补充其他附属信息后进行,返回分页的数据结构

初次之后查找数据,在Query阶段通过scroll_id找到对应的快照,然后用lastPageNo,realPageNo将原来的查询语句添加查询条件( pageNo=realPageNo + 1),在快照中找数据。

三、成果分享

技改方案上线后,集中观测中秋流量,日常千万级的商品过滤次数,平均每次请求过滤3-4个商品

图4 二次过滤开启前和开启后的效果

图5 中秋期间锦礼平台流量

图6 中秋期间商品过滤的次数

图7 商品搜索过滤后的接口性能

四、思考感悟

在产品迭代演进中,我们坚信,技术能够为我们的业务带来无限可能。它能帮助我们创新、优化流程、提升效率。同时,我们也明白,技术并非万能的。但正是对技术的这种深深敬畏,让我们始终保持谦逊和开放的心态,使我们始终能找到新的突破口,超越自我。

一个小小的技巧,也可以给业务带来大大的价值!!!

作者:京东零售 毛辰飞

来源:京东云开发者社区 转载请注明来源

1、响应式本质

就是把数据和函数相关联起来,当数据变化时,函数自动执行。当然这对于函数和数据也是有要求的

函数必须是以下几种:

render

computed

watch

watchEffect

数据必须是以下几种:

响应式数据

在函数中用到的数据

2、例子

2.1

<template>
  <div class="responsive">
    <h1>responsive</h1>
    <div>传入的值:{{ count }}</div>
    <br>
    <div>doubled:{{doubleCount}}</div>
    <br>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
  count: {
    type: Number,
    default: 0
  }
})
const doubleCount =ref(props.count * 2)
</script>

<style scoped>

</style>

结果:

当我们点击增加按钮时,页面并没有发生变化,这是因为我们的doubleCount并没有响应式。

原因:const doubleCount =ref(props.count * 2)这一过程不涉及到任何函数,数据和数据之间是无法形成关联的,所以doubleCount并不是响应式的。

2.2

<template>
  <div class="responsive">
    <h1>responsive</h1>
    <div>传入的值:{{ count }}</div>
    <br>
    <div>doubled:{{doubleCount}}</div>
    <br>
  </div>
</template>

<script setup>
import { ref, computed ,watchEffect} from 'vue'
const props = defineProps({
  count: {
    type: Number,
    default: 0
  }
})
const doubleCount =ref(0)
watchEffect(() => {
  console.log('watchEffect')
  doubleCount.value = props.count * 2
})
</script>

<style scoped>

</style>

结果:

当我们点击增加按钮时,页面发生了变化,这是因为我们的doubleCount是响应式的。

原因:
函数与数据关联起来了;

1、watchEffect是一个函数,props.count是一个响应式数据,且在watchEffect中用到了,所以props.count变化了,watchEffect就会执行,导致doubleCount变化;
2、doubleCount也是个响应式数据,在render函数中用到了,所以doubleCount变化了,render函数就会执行,更新页面。

2.3

<template>
  <div class="responsive">
    <h1>responsive</h1>
    <div>传入的值:{{ count }}</div>
    <br>
    <div>doubled:{{doubleCount}}</div>
    <br>
  </div>
</template>

<script setup>
import { ref, computed ,watchEffect} from 'vue'
const props = defineProps({
  count: {
    type: Number,
    default: 0
  }
})
function useDouble(count) {
  const doubleCount =ref(0)
  watchEffect(() => {
    console.log('watchEffect')
    doubleCount.value = count * 2
  })
  return doubleCount
}
const doubleCount = useDouble(props.count)

</script>

<style scoped>

</style>

结果:

当我们点击增加按钮时,页面未发生变化。

原因:
useDouble函数传的参数是一个原始值,没有读到任何响应式数据。所以doubleCount不会更新,从而render函数也不会执行。

2.4

<template>
  <div class="responsive">
    <h1>responsive</h1>
    <div>传入的值:{{ count }}</div>
    <br>
    <div>doubled:{{doubleCount}}</div>
    <br>
  </div>
</template>

<script setup>
import { ref, computed ,watchEffect} from 'vue'
const props = defineProps({
  count: {
    type: Number,
    default: 0
  }
})
const doubleCount = computed(() => {
  console.log('computed')
  return props.count * 2
})

</script>

<style scoped>

</style>

结果:

当我们点击增加按钮时,页面发生了变化。

原因:

1、computed是一个函数,props.count是一个响应式数据,且在computed中用到了,所以props.count变化了,computed就会执行,导致doubleCount变化;
2、doubleCount也是个响应式数据,在render函数中用到了,所以doubleCount变化了,render函数就会执行,更新页面。

2.5

<template>
  <div class="responsive">
    <h1>responsive</h1>
    <div>传入的值:{{ count }}</div>
    <br>
    <div>doubled:{{doubleCount}}</div>
    <br>
  </div>
</template>

<script setup>
import { ref, computed ,watchEffect} from 'vue'
const props = defineProps({
  count: {
    type: Number,
    default: 0
  }
})
function useDouble(props) {
  const doubleCount =ref(0)
  watchEffect(() => {
    console.log('watchEffect')
    doubleCount.value = props.count * 2
  })
  return doubleCount
}
const doubleCount = useDouble(props)

</script>

<style scoped>

</style>

结果:

当我们点击增加按钮时,页面发生变化。
原因:
1、props是一个响应式数据,跟watchEffect关联起来了,所以当props.count变化时,watchEffect就会执行,导致doubleCount变化;
2、doubleCount也是个响应式数据,在render函数中用到了,所以doubleCount变化了,render函数就会执行,更新页面。

tips:VueUse库中的基本都是传的props。

Nuxt.js 生成sitemap站点地图文件

背景介绍

​ 使用
nuxt
框架生成静态文件支持SEO优化,打包之后需要生成一个
sitemap.xml
文件方便提交搜索引擎进行收录。官网有提供一个插件
sitemap
但是如果是动态路由需要手动一个个配置比较麻烦,无法自动检索生成。所以自己编写一个生成 sitemap 模块

准备工作

创建
nuxt
项目,参考
中文官网
。安装
JavaScript
模板ejs工具

$ npm install ejs

相关网站

sitemap模块

项目根目录创建
modules
目录,以及对应文件,详细文件内容放在文末。

├─modules
│  └─robots.ejs // robots模板
│  └─sitemap.js // 站点地图js
│  └─template.ejs //sitemap 模板

配置 nuxt.config.js


modules
数组增加以下内容
modules/sitemap
刚才自定义模块,
excludes
需要排除的目录,
hostname
站点域名

nuxt.config.js

export default {
  ...省略
  // Modules: https://go.nuxtjs.dev/config-modules
  modules: [
    ...省略,
    ['modules/sitemap',
      {
        excludes: ['_nuxt', 'img'],
        hostname: 'https://www.example.com'
      }
    ],
  ],
}

执行命令生成静态资源

$npm run generate

打开项目根目录下
dist
(默认输出路径),会多出两个文件

├─robots.txt
├─sitemap.xml

结果展示

sitemap

robots

官方示例 modules

编写自己的模块

模块就是函数。它们可以打包为 npm 模块或直接包含在项目源代码中。

nuxt.config.js

export default {
  exampleMsg: 'hello',
  modules: [
    // Simple usage
    '~/modules/example',
    // Passing options directly
    ['~/modules/example', { token: '123' }]
  ]
}

modules/example.js

export default function ExampleModule(moduleOptions) {
  console.log(moduleOptions.token) // '123'
  console.log(this.options.exampleMsg) // 'hello'

  this.nuxt.hook('ready', async nuxt => {
    console.log('Nuxt is ready')
  })
}

// REQUIRED if publishing the module as npm package
module.exports.meta = require('./package.json')

1) ModuleOptions

moduleOptions

modules
这是用户使用数组传递的对象 。我们可以用它来定制它的行为。

顶级选项

有时,如果我们可以在注册模块时使用顶级选项会更方便
nuxt.config.js
。这使我们能够组合多个选项源。

nuxt.config.js

export default {
  modules: [['@nuxtjs/axios', { anotherOption: true }]],

  // axios module is aware of this by using `this.options.axios`
  axios: {
    option1,
    option2
  }
}

2) this.options

this.options
:您可以使用此参考直接访问 Nuxt 选项。
nuxt.config.js
这是分配有所有默认选项的用户内容 。它可用于模块之间的共享选项。

模块.js

export default function (moduleOptions) {
  // `options` will contain option1, option2 and anotherOption
  const options = Object.assign({}, this.options.axios, moduleOptions)

  // ...
}

modules文件

modules/robots.ejs

# robots.txt
User-agent: Baiduspider
Disallow:
User-agent: Sosospider
Disallow:
User-agent: sogou spider
Disallow:
User-agent: YodaoBot
Disallow:
User-agent: Googlebot
Disallow:
User-agent: Bingbot
Disallow:
User-agent: Slurp
Disallow:
User-agent: Teoma
Disallow:
User-agent: ia_archiver
Disallow:
User-agent: twiceler
Disallow:
User-agent: MSNBot
Disallow:
User-agent: Scrubby
Disallow:
User-agent: Robozilla
Disallow:
User-agent: Gigabot
Disallow:
User-agent: googlebot-image
Disallow:
User-agent: googlebot-mobile
Disallow:
User-agent: yahoo-mmcrawler
Disallow:
User-agent: yahoo-blogs/v3.9
Disallow:
User-agent: psbot
Disallow:
Disallow: /bin/
Disallow: /js/
Disallow: /img/
Sitemap: <%= hostname %>/sitemap.xml

modules/sitemap.js

/**
 * @description 生成 sitemap robots 模块
 * @author 方圆百里
 * @time 2023年10月12日
 */

const path = require('path');
const fs = require('fs');
const ejs = require('ejs');
/**
 * @description 获取当前目录下载的所有路径 -同步
 * @author 方圆百里
 *
 * @param {String} dir 文件路径
 * @returns {Array} 返回路径数组
 */
const loadFiles = (dir) => {
  try {
    const data = fs.readdirSync(dir);
    return data;
  } catch (e) {
    console.error('获取目录路径异常', e)
    return undefined;
  }
}

/**
 * @description 获取文件信息
 * @author 方圆百里
 *
 * @param {String} dir 文件路径
 * @returns {Array} 返回路径数组
 */
const statFile = (full_path) => {
  try {
    const stat = fs.statSync(full_path);
    stat.path = full_path;
    return stat;
  } catch (e) {
    console.error('获取目录路径异常', e)
    return undefined;
  }
}

/**
 * @description 递归处理文件路径
 * @author 方圆百里
 *
 * @param {String} dir 文件路径
 * @param {String} list 文件信息数组
 * @returns {Array} 返回路径数组
 */
const handleFiles = (dir, list = [], excludes) => {
  // 1、加载当前目录下所有路径,包含文件夹和文件
  const data = loadFiles(dir);
  if (data) {
    data.forEach(item => {
      if (!excludes.includes(item)) {
        // 2、拼接绝对路径
        const absolutePath = path.join(dir, item)
        // 3、获取文件基本信息
        const stat = statFile(absolutePath);
        // 4、如果是文件,处理基本信息
        if (stat.isFile()) {
          list.push({
            size: stat.size,
            time: stat.ctime,
            ...path.parse(stat.path)
          })
        } else { // 5、目录递归进行处理
          handleFiles(stat.path, list, excludes);
        }
      }
    })
  }
  return list;
}

/**
 * @description 格式化日期
 * @author 方圆百里
 *
 * @param {Date} date 日期
 * @returns {String} 2023-10-12
 */
const formatYear = (date) => {
  // 获取年、月和日
  const year = date.getFullYear();
  const month = (date.getMonth() + 1).toString().padStart(2, '0'); // 月份从0开始,需要加1,同时确保两位数格式
  const day = date.getDate().toString().padStart(2, '0'); // 确保两位数格式
  // 格式化日期
  return `${year}-${month}-${day}`;
}

/**
 * @description 生成站点地图
 * @author 方圆百里
 *
 * @param {String} dist 打包后文件路径
 * @param {String} hostname 主机名称
 * @param {Array} excludes 排除路径
 *
 */
const generateSitemap = (dist, hostname, excludes) => {
  const data = handleFiles(dist, [], excludes)
  const set = new Set();
  for (var i = 0; i < data.length; i++) {
    const f = data[i];
    if (f.ext === '.html') {
      const relative = f.dir.replace(dist, "")
      if (relative) {
        const paths = relative.split(path.sep);
        let loc = hostname;
        for (var x = 1; x < paths.length; x++) {
          loc += "/" + paths[x];
        }
        set.add({
          loc: loc,
          time: formatYear(f.time)
        });
      }
    }
  }
  // 读取模板文件
  const template = fs.readFileSync('modules/template.ejs', 'utf-8');
  // 提供模板数据
  const datas = {
    urls: set
  };
  // 使用模板引擎渲染模板
  const renderedContent = ejs.render(template, datas);
  // 写入生成的文件
  fs.writeFileSync(path.join(dist, 'sitemap.xml'), renderedContent);
  console.log('sitemap.xml 生成成功!');

  const robotsRendered = ejs.render(fs.readFileSync('modules/robots.ejs', 'utf-8'), {
    hostname
  });
  // 写入生成的文件
  fs.writeFileSync(path.join(dist, 'robots.txt'), robotsRendered);
  console.log('robots.txt 生成成功!');
}
export default function ExampleModule(moduleOptions) {
  const dist = this.options.generate?.dir || 'dist'; // 打包输出路径
  const hostname = moduleOptions.hostname || 'https://www.example.com'; // 主机名称
  const excludes = moduleOptions.excludes || ['.nuxt']; // 排除路径
  console.log('打包输出路径:=====>', dist)
  console.log('主机名称:=====>', hostname)
  console.log('排除路径:=====>', excludes)

  this.nuxt.hook('generate:done', async generator => {
    // 这将在Nuxt生成页面之之后调用
    console.log('执行 generate 完成')
    generateSitemap(dist, hostname, excludes)

  })
}

// 将模块发布为npm包
module.exports.meta = require('../package.json')

modules/template.ejs

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"
  xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0"
  xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
  xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
  <url>
    <% urls.forEach(function(item) { %>
       <loc><%= item.loc %></loc>
       <lastmod><%= item.time %></lastmod>
       <changefreq>monthly</changefreq>
       <priority>0.8</priority>
    <% }); %>
  </url>
</urlset>

百分比堆叠式柱状图
是一种特殊的柱状图,它的每根柱子是等长的,总额为100%。
柱子内部被分割为多个部分,高度由该部分占总体的百分比决定。

百分比堆叠式柱状图
不显示数据的“绝对数值”,而是显示“相对比例”。
但同时,它也仍然具有柱状图的固有功能,即“比较”——我们可以通过比较多个柱子的构成,分析数值之间的相对差异,或者得出数值变化的趋势。

1. 主要元素

百分比柱状图是一种用于可视化比较不同类别或组的百分比或比例的图表。

它的主要元素包括:

  1. 横轴:表示数据的主分类。
  2. 纵轴:每个子分类的比例关系。
  3. 堆叠的矩形:每个柱状图由多个堆叠部分组成,和堆叠柱状图不同的是,每个柱子都是一样高的。
  4. 图例:每个堆叠部分代表的意义。

图片来自 antv 官网

2. 适用的场景

百分比柱状图适用的场景很多,比如:

  • 市场份额
    :比较不同产品或服务的市场份额,帮助决策者了解市场竞争情况。
  • 人口比例
    :显示不同地区或不同群体的人口比例,或不同年龄段的人口比例。
  • 问卷调查结果
    :比较不同选项或答案的频率或比例,或者用户对产品特性的满意度。
  • 部门预算分配
    :显示不同部门或项目的预算分配比例,帮助管理者了解资源分配情况。
  • 等等。。。

3. 不适用的场景

百分比柱状图也有不适用于的场景,比如:

  • 比较绝对数值
    :如果需要比较具体的数值大小而不仅仅是比例,那么百分比柱状图可能不是最合适的选择。
  • 数据存在重叠
    :如果不同类别的数据存在重叠或者相互依赖的情况,百分比柱状图可能无法清晰地展示比例关系。
  • 数据量过大或过小
    :如果数据量过大或过小,百分比柱状图可能无法有效地显示比例关系。

4. 分析实战

和上一篇堆叠柱状图使用相同的原始数据,绘制图形之后可以看看这两种柱状图展示分析结果的区别。

4.1. 数据来源

数据来自国家统计局公开的
人民生活
数据,可从下面的网址下载:
https://databook.top/nation/A0A

使用的是其中
A0A0A.csv
文件(全国居民主要食品消费量)

fp = "d:/share/A0A0A.csv"

df = pd.read_csv(fp)
df

image.png

4.2. 数据清理

选取和上一篇堆叠柱状图一样,还是5类:

  1. 居民人均蔬菜及食用菌消费量(千克)
  2. 居民人均肉类消费量(千克)
  3. 居民人均禽类消费量(千克)
  4. 居民人均水产品消费量(千克)
  5. 居民人均蛋类消费量(千克)

和堆叠柱状图不同的是,绘制百分比柱状图用的是百分比数值,
所有要把原始数据中每年的绝对数值转换为百分比数值。

data = df[(df["sj"] >= 2013) & 
        (df["sj"] <= 2021) & 
        (df["zb"].isin(["A0A0A03", 
                        "A0A0A04",
                        "A0A0A05",
                        "A0A0A06",
                        "A0A0A07"]))].copy()

data["年消耗总量"] = data.groupby("sj").value.transform("sum")
data["各类消耗量占比"] = data["value"] / data["年消耗总量"]

data.loc[:, ["sjCN", "zbCN", "各类消耗量占比"]].head(10)

image.png

4.3. 分析结果可视化

import matplotlib.ticker as mticker

data = data.sort_values("sj")
data["各类消耗量占比"] = data["各类消耗量占比"]*100

with plt.style.context("seaborn-v0_8"):
    fig = plt.figure()
    ax = fig.add_axes([0.1, 0.1, 0.8, 0.8])

    years = data["sjCN"].drop_duplicates(keep="first").tolist()
    bar_data = {
        "蔬菜及菌类(%)": data[data["zb"] == "A0A0A03"]["各类消耗量占比"].tolist(),
        "肉类(%)": data[data["zb"] == "A0A0A04"]["各类消耗量占比"].tolist(),
        "禽类(%)": data[data["zb"] == "A0A0A05"]["各类消耗量占比"].tolist(),
        "水产品(%)": data[data["zb"] == "A0A0A06"]["各类消耗量占比"].tolist(),
        "蛋类(%)": data[data["zb"] == "A0A0A07"]["各类消耗量占比"].tolist(),
    }

    bottom = np.zeros(len(years))
    for key, vals in bar_data.items():
        ax.bar(years, vals, label=key, bottom=bottom)
        bottom += vals

    # 设置Y轴刻度的显示格式
    ax.set_ylim(0, 110)
    yticks = ax.get_yticks().tolist()
    ax.yaxis.set_major_locator(mticker.FixedLocator(yticks))
    ax.set_yticklabels(["{}%".format(x) for x in yticks])

    ax.set_title("全国居民主要粮食消耗情况")
    ax.legend(loc="upper left", ncol=5)

image.png

百分比柱状图每年的数据高度都一样,与堆叠柱状图相比,更容易
比较
每个种类粮食的消耗情况。
不过,这种图看不出粮食
总量的变化
情况了。