汪雪来

汪雪来 查看完整档案

填写现居城市  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

汪雪来 发布了文章 · 3月7日

记录一次直播间开发的业务拆解以及埋坑经历

需求:

  1. 实现obs推流,h5页面播放视频。
  2. 在视频播放的同时页面,页面之外的区域实现一个聊天室的功能。
  3. 支持生成H5海报,支持对外分享。
  4. 支持在pc环境下运行,同时需要在pc端获取到用户微信授权。

插件:

  1. laravel-echo 官方文档https://learnku.com/docs/lara...
  2. socket.io laravel-echo需要用到这个插件
  3. html2canvas 官方文档
    http://html2canvas.hertzen.com/
  4. DPlayer 官方文档 http://dplayer.js.org/
  5. vant 官方文档 https://youzan.github.io/vant...

需求拆分:
我们的直播业务其实很简单,就两个页面,其中一个页面是直播课堂介绍页面,另外一个页面是直播房间页面。直播房间如下图所示:
企业微信截图_2af75959-919f-4984-9471-2d3ac43a7c95.png

分成3个区域 视频直播区,聊天区,输入区
视频区域的细节拆解
直播时,视频是通过obs向腾讯云推流,然后前端向后段拿到腾讯云返回的视频链接,进行播放,后端返回的视频格式
https://t1.pulllive.qingxiao....
https://t1.pulllive.qingxiao....
rtmp://t1.pullive.qingxiao.online/live/course_5_1c95d80ec5ed6f7540a49a08b8b5ac5c?txSecret=b993f807738bddab7a6da24ef29beada&txTime=5E6382C0
是这3种类型的视频流链接
一开始我使用的是原生的浏览器video标签,但是经过实践发现
rmpt格式的流安卓手机可以播放 pc播放不了
m3u8格式ios和安卓手机播放不了 pc播放不了
flv格式的ios,安卓,pc都不行最终放弃了使用原生video标签

后来改用了video.js,都可以播放了,但是发现他在ios和安卓上面都只是一个系统原生video标签,在ios上是好的,但是在安卓手机上会发现他的层级很高,并且点击播放的时候会全屏。安卓手机播放之后层级过高的问题可以通过设置video标签的x5-video-player-type:h5-page可以解决,设置的之后可以在视频上面悬浮元素,并且不会被遮挡。虽然满足需求了,但是后来发现了比他更好用的视频播放器dplayer

查看原文

赞 0 收藏 0 评论 0

汪雪来 收藏了文章 · 2019-10-15

前后端分离开发中动态菜单的两种实现方案

关于前后端分离开发中的权限处理问题,松哥之前写过一篇文章和大家聊这个问题:

但是最近有小伙伴在学习微人事项目时,对动态菜单这一块还是有疑问(即不同用户登录成功后会看到不同的菜单项),因此松哥打算再来写一篇文章和大家聊一聊前后端分离开发中的动态菜单问题。

1. 一个原则

做权限管理,一个核心思想就是后端做权限控制,前端做的所有工作都只是为了提高用户体验,我们不能依靠前端展示或者隐藏一个按钮来实现权限控制,这样肯定是不安全的。

就像用户注册时需要输入邮箱地址,前端校验之后,后端还是要校验,两个校验目的不同,前端校验是为了提高响应速度,优化用户体验,后端校验则是为了确保数据完整性。权限管理也是如此,前端按钮的展示/隐藏都只是为了提高用户体验,真正的权限管理需要后端来实现。

这是非常重要的一点,做前后端分离开发中的权限管理,我们首先要建立上面这样的思考框架,然后在这样的框架下,去考虑其他问题。

因此,下文我会和大家分享两种方式实现动态菜单,这两种方式仅仅只是探讨如何更好的给用户展示菜单,而不是探讨权限管理,因为权限管理是在后端完成的,也必须在后端完成。

2. 具体实现

一旦建立起这样的思考框架,你会发现动态菜单的实现办法太多了。

动态菜单就是用户登录之后看到的菜单,不用角色的用户登录成功之后,会看到不用的菜单项,这个动态菜单要怎么实现呢?整体来说,有两种不同的方案,松哥曾经做过的项目中,两种方案也都有用过,这里分别来和大家分享一下。

2.1 后端动态返回

后端动态返回,这是我在微人事中采用的方案。微人事中,权限管理相关的表一共有五张表,如下:

其中 hr 表就是用户表,用户登录成功之后,可以查询到用户的角色,再根据用户角色去查询出来用户可以操作的菜单(资源),然后把这些可以操作的资源,组织成一个 JSON 数据,返回给前端,前端再根据这个 JSON 渲染出相应的菜单。以微人事为例,我们返回的 JSON 数据格式如下:

[
    {
        "id":2,
        "path":"/home",
        "component":"Home",
        "name":"员工资料",
        "iconCls":"fa fa-user-circle-o",
        "children":[
            {
                "id":null,
                "path":"/emp/basic",
                "component":"EmpBasic",
                "name":"基本资料",
                "iconCls":null,
                "children":[

                ],
                "meta":{
                    "keepAlive":false,
                    "requireAuth":true
                }
            }
        ],
        "meta":{
            "keepAlive":false,
            "requireAuth":true
        }
    }
]

这样的 JSON 在前端中再进行二次处理之后,就可以使用了,前端的二次处理主要是把 component 属性的字符串值转为对象。这一块具体操作大家可以参考微人事项目(具体在:https://github.com/lenve/vhr/blob/master/vuehr/src/utils/utils.js),我就不再赘述了。

这种方式的一个好处是前端的判断逻辑少一些,后端也不算复杂,就是一个 SQL 操作,前端拿到后端的返回的菜单数据,稍微处理一下就可以直接使用了。另外这种方式还有一个优势就是可以动态配置资源-角色以及用户-角色之间的关系,进而调整用户可以操作的资源(菜单)。

2.2 前端动态渲染

另一种方式就是前端动态渲染,这种方式后端的工作要轻松一些,前端处理起来麻烦一些,松哥去年年末帮一个律所做的一个管理系统,因为权限上比较容易,我就采用了这种方案。

这种方式就是我直接在前端把所有页面都在路由表里边定义好,然后在 meta 属性中定义每一个页面需要哪些角色才能访问,例如下面这样:

[
    {
        "id":2,
        "path":"/home",
        "component":Home,
        "name":"员工资料",
        "iconCls":"fa fa-user-circle-o",
        "children":[
            {
                "id":null,
                "path":"/emp/basic",
                "component":EmpBasic,
                "name":"基本资料",
                "iconCls":null,
                "children":[

                ],
                "meta":{
                    "keepAlive":false,
                    "requireAuth":true,
                    "roles":['admin','user']
                }
            }
        ],
        "meta":{
            "keepAlive":false,
            "requireAuth":true
        }
    }
]

这样定义表示当前登录用户需要具备 admin 或者 user 角色,才可以访问 EmpBasic 组件,当然这里不是说我这样定义了就行,这个定义只是一个标记,在项目首页中,我会遍历这个数组做菜单动态渲染,然后根据当前登录用户的角色,再结合当前组件需要的角色,来决定是否把当前组件所对应的菜单项渲染出来。

这样的话,后端只需要在登录成功后返回当前用户的角色就可以了,剩下的事情则交给前端来做。不过这种方式有一个弊端就是菜单和角色的关系在前端代码中写死了,以后如果想要动态调整会有一些不方便,可能需要改代码。特别是大项目,权限比较复杂的时候,调整就更麻烦了,所以这种方式我一般建议在一些简单的项目中使用。

3. 结语

虽然我在微人事中使用了第一种方式,不过如果小伙伴是一个新项目,并且权限问题不是很复杂的话,我还是建议尝试一下第二种方式,感觉要方便一些。

不过在公司中,动态菜单到底在前端做还是后端做,可能会有一个前后端团队沟(si)通(bi)的过程,赢了的一方就可以少写几行代码了。

关注公众号【江南一点雨】,专注于 Spring Boot+微服务以及前后端分离等全栈技术,定期视频教程分享,关注后回复 Java ,领取松哥为你精心准备的 Java 干货!

查看原文

汪雪来 收藏了文章 · 2019-10-15

动态海报营销FabricJs方案

简介

Fabric.js是一个可以简化Canvas程序编写的库。 Fabric.js为Canvas提供所缺少的对象模型, svg parser, 交互和一整套其他不可或缺的工具。Fabric.js可以做很多事情,如下:

  • 在Canvas上创建、填充图形(包括图片、文字、规则图形和复杂路径组成图形)。
  • 给图形填充渐变颜色。
  • 组合图形(包括组合图形、图形文字、图片等)。
  • 设置图形动画集用户交互。
  • 生成JSON, SVG数据等。
  • 生成Canvas对象自带拖拉拽功能。

使用教程

安装

npm 安装

npm install fabric --save

cdn引用

<script data-original="http://cdnjs.cloudflare.com/ajax/libs/fabric.js/2.4.6/fabric.min.js"></script>// 貌似国外相对较慢

可以在https://www.bootcdn.cn/fabric... 找到更快的CDN来源

在使用前,先看下我做的总体效果如下:
4.jpg

初始化

创建了一个基本的画布

<canvas id="canvas" width="350" height="200"></canvas>
const card = new fabric.Canvas('canvas') 

// ...这里可以写canvas对象的一些配置,后面将会介绍

// 如果<canvas>标签没设置宽高,可以通过js动态设置
card.setWidth(350)
card.setHeight(200)

对画布的交互

常用监听事件如下:

  • mouse:down:鼠标按下时
  • mouse:move:鼠标移动时
  • mouse:up:鼠标抬起时
    var canvas = new fabric.Canvas('canvas');
    canvas.on('mouse:down', function(options) {
        console.log(options.e.clientX, options.e.clientY)
    })

监听画布对象事件以便“上下一步”的神操作

以下为常用的事件:

  • object:added 添加图层
  • object:modified 编辑图层
  • object:removed 移除图层
  • selection:created 初次选中图层
  • selection:updated 图层选择变化
  • selection:cleared 清空图层选中

1.jpg

下面是原文,更多参考__fabricjs官网事件__:

So which other events are available in Fabric? Well, from mouse-level ones there are "mouse:down", "mouse:move", and "mouse:up". From generic ones, there are "after:render". Then there are selection-related events: "before:selection:cleared", "selection:created", "selection:cleared". And finally, object ones: "object:modified", "object:selected", "object:moving", "object:scaling", "object:rotating", "object:added", and "object:removed"

设置画布背景

// 读取图片地址,设置画布背景
fabric.Image.fromURL('xx/xx/bg.jpg', (img) => {
  img.set({
   // 通过scale来设置图片大小,这里设置和画布一样大
    scaleX: card.width / img.width,
    scaleY: card.height / img.height,
  });
  // 设置背景
  card.setBackgroundImage(img, card.renderAll.bind(card));
  card.renderAll();
});

向画布添加图层对象

fabric.js提供了很多对象,除了基本的 Rect,Circle,Line,Ellipse,Polygon,Polyline,Triangle对象外,还有如 Image,Textbox,Group等更高级的对象,这些都是继承自Fabric的Object对象。

/**
* 如何向画布添加一个Image对象?
*/

// 方式一(通过img元素添加)
const imgElement = document.getElementById('my-image');
const imgInstance = new fabric.Image(imgElement, {
  left: 100, // 图片相对画布的左侧距离
  top: 100, // 图片相对画布的顶部距离
  angle: 30, // 图片旋转角度
  opacity: 0.85, // 图片透明度
  // 这里可以通过scaleX和scaleY来设置图片绘制后的大小,这里为原来大小的一半
  scaleX: 0.5, 
  scaleY: 0.5
});
// 添加对象后, 如下图
card.add(imgInstance);
/**
* 如何向画布添加一个Textbox对象?
*/

const textbox = new fabric.Textbox('这是一段文字', {
    left: 50,
    top: 50,
    width: 150,
    fontSize: 20, // 字体大小
    fontWeight: 800, // 字体粗细
    // fill: 'red', // 字体颜色
    // fontStyle: 'italic',  // 斜体
    // fontFamily: 'Delicious', // 设置字体
    // stroke: 'green', // 描边颜色
    // strokeWidth: 3, // 描边宽度
    hasControls: false,
    borderColor: 'orange',
    editingBorderColor: 'blue' // 点击文字进入编辑状态时的边框颜色
});
// 添加文字后,如下图
card.add(textbox);

获取当前选中的图层对象

// 方式一
this.selectedObj = card.getActiveObject(); // 返回当前画布中被选中的图层 

// 方式二
card.on('selection:created', (e) => {
    // 选中图层事件触发时,动态更新赋值
    this.selectedObj = e.target
})

常规操作

this.selectedObj.rotate(angle); //旋转图层
this.selectedObj.set({

scaleX: -this.selectedObj.scaleX, // 水平翻转

})
card.remove(this.selectedObj) // 传入需要移除的object
this.selectedObj.bringForward();// 上移图层
this.selectedObj.sendBackwards();// 下移图层
card.moveTo(object, index);// 也可以使用canvas对象的moveTo方法,移至图层到指定位置

// 所有图层的操作之后,都需要调用这个方法
card.renderAll()

手机相册拍照图片尺寸太大导致拖动麻烦

主要是在添加图片对象的时候,有两个参数可以应用起来,分别是scaleX,scaleY参数,通过这两个参数,可以对应地缩放图片大小,方便图片能完整地在canvas画布体现出来。

先将手机图片加载完毕,算出宽和高,根据自己的画布纵横对比重新算出 图片的缩放参数即可。

          new Promise(function(res,rej){
              let img = new Image();
              img.onload = function(){
                 console.log(img.width,img.height);
                 res([img.width,img.height]);
              }
              img.src = file.content;
          }).then(function(response){
              let width = response[0];
              let height = response[1];
              let xYRet = 0.61;    //300/490  纵横对比
              let targetRepix = 1;

              if(height > 490)
              {
                targetRepix = (490/height).toFixed(2);
              }

              if(width > 300)
              {
                targetRepix = (300/width).toFixed(2);
              }


              fabric.Image.fromURL(file.content, (img) => {
                  img.set({
                      top:30,
                      left:30,
                      scaleX: targetRepix, 
                      scaleY: targetRepix,
                      hasControls: true, // 是否开启图层的控件
                      borderColor: 'orange', // 图层控件边框的颜色
                  });
                  // 添加对象后, 如下图
                  card.add(img);
              });
          })

文字粗细和颜色随意设

//其中selectedObj 是当前选中的文字对象

    this.selectedObj.set({
        fontWeight:xxx  //改变粗细
    });

    this.selectedObj.set({
        fill:xxx    //改变颜色
    });

2.jpg

3.png

更改选中对象的框样式

        card.on('selection:updated', (e) => {
          console.log('selection:updated', e.target)
          this.setSelectedObj(e.target)

            //通过选中的对象更改样式
            e.target.set({
                transparentCorners: false,
                cornerColor: 'blue',
                cornerStrokeColor: 'red',
                borderColor: 'red',
                cornerSize: 12,
                padding: 10,
                cornerStyle: 'circle',
                borderDashArray: [3, 3]
            });

        })

效果如下图:

6.jpg

画布序列化与反序列化————记录状态记录

框架提供了如 toJSON 和 loadFromJSON 方法,作用分别为导出当前画布的json信息,加载json画布信息来还原画布状态。

// 导出当前画布信息
const currState = card.toJSON(); // 导出的Json如下图
// 加载画布信息
card.loadFromJSON(lastState, () => {
  card.renderAll();
});

将画布导出成图片

const dataURL = card.toDataURL({
  format: 'jpeg', // jpeg或png
  quality: 0.8 // 图片质量,仅jpeg时可用
  // 截取指定位置和大小
  //left: 100, 
  //top: 100,
  //width: 200,
  //height: 200
});
查看原文

汪雪来 收藏了文章 · 2019-10-15

一篇文章搞定前端性能优化面试

前言

虽然前端开发作为 GUI 开发的一种,但是存在其特殊性,前端的特殊性就在于“动态”二字,传统 GUI 开发,不管是桌面应用还是移动端应用都是需要预先下载的,只有先下载应用程序才会在本地操作系统运行,而前端不同,它是“动态增量”式的,我们的前端应用往往是实时加载执行的,并不需要预先下载,这就造成了一个问题,前端开发中往往最影响性能的不是什么计算或者渲染,而是加载速度,加载速度会直接影响用户体验和网站留存。

《Designing for Performance》的作者 Lara Swanson在2014年写过一篇文章《Web性能即用户体验》,她在文中提到“网站页面的快速加载,能够建立用户对网站的信任,增加回访率,大部分的用户其实都期待页面能够在2秒内加载完成,而当超过3秒以后,就会有接近40%的用户离开你的网站”。

值得一提的是,GUI 开发依然有一个共同的特殊之处,那就是 体验性能 ,体验性能并不指在绝对性能上的性能优化,而是回归用户体验这个根本目的,因为在 GUI 开发的领域,绝大多数情况下追求绝对意义上的性能是没有意义的.

比如一个动画本来就已经有 60 帧了,你通过一个吊炸天的算法优化到了 120 帧,这对于你的 KPI 毫无用处,因为这个优化本身没有意义,因为除了少数特异功能的异人,没有人能分得清 60 帧和 120 帧的区别,这对于用户的体验没有任何提升,相反,一个首屏加载需要 4s 的网站,你没有任何实质意义上的性能优化,只是加了一个设计姐姐设计的 loading 图,那这也是十分有意义的优化,因为好的 loading 可以减少用户焦虑,让用户感觉没有等太久,这就是用户体验级的性能优化.

因此,我们要强调即使没有对性能有实质的优化,通过设计提高用户体验的这个过程,也算是性能优化,因为 GUI 开发直面用户,你让用户有了性能快的 错觉,这也叫性能优化了,毕竟用户觉得快,才是真的快...

文章目录

  1. 首屏加载优化
  2. 路由跳转加载优化

1.首屏加载

首屏加载是被讨论最多的话题,一方面web 前端首屏的加载性能的确普遍较差,另一方面,首屏的加载速度至关重要,很多时候过长的白屏会导致用户还没有体验到网站功能的时候就流失了,首屏速度是用户留存的关键点。

以用户体验的角度来解读首屏的关键点,如果作为用户我们从输入网址之后的心里过程是怎样的呢?

当我们敲下回车后,我们第一个疑问是:
"它在运行吗?" 
这个疑问一直到用户看到页面第一个绘制的元素为止,这个时候用户才能确定自己的请求是有效的(而不是被墙了...),然后第二个疑问:
"它有用吗?" 
如果只绘制出无意义的各种乱序的元素,这对于用户是不可理解的,此时虽然页面开始加载了,但是对于用户没有任何价值,直到文字内容、交互按钮这些元素加载完毕,用户才能理解页面,这个时候用户会尝试与页面交互,会有第三个疑问:
"它能使用了吗?" 
直到用户成功与页面互动,这才算是首屏加载完毕了.

在第一个疑问和第二个疑问之间的等待期,会出现白屏,这是优化的关键.

1.1 白屏的定义

不管是我们如何优化性能,首屏必然是会出现白屏的,因为这是前端开发这项技术的特点决定的。

那么我们先定义一下白屏,这样才能方便计算我们的白屏时间,因为白屏的计算逻辑说法不一,有人说要从首次绘制(First Paint,FP)算起到首次内容绘制(First Contentful Paint,FCP)这段时间算白屏,我个人是不同意的,我个人更倾向于是从路由改变起(即用户再按下回车的瞬间)到首次内容绘制(即能看到第一个内容)为止算白屏时间,因为按照用户的心理,在按下回车起就认为自己发起了请求,而直到看到第一个元素被绘制出来之前,用户的心里是焦虑的,因为他不知道这个请求会不会被响应(网站挂了?),不知道要等多久才会被响应到(网站慢?),这期间为用户首次等待期间。

白屏时间 = firstPaint - performance.timing.navigationStart

以webapp 版的微博为例(微博为数不多的的良心产品),经过 Lighthouse(谷歌的网站测试工具)它的白屏加载时间为 2s,是非常好的成绩。
image.png


1.2 白屏加载的问题分析

在现代前端应用开发中,我们往往会用 webpack 等打包器进行打包,很多情况下如果我们不进行优化,就会出现很多体积巨大的 chunk,有的甚至在 5M 左右(我第一次用 webpack1.x 打包的时候打出了 8M 的包),这些 chunk 是加载速度的杀手。

浏览器通常都有并发请求的限制,以 Chrome 为例,它的并发请求就为 6 个,这导致我们必须在请求完前 6 个之后,才能继续进行后续请求,这也影响我们资源的加载速度。

当然了,网络、带宽这是自始至终都影响加载速度的因素,白屏也不例外.

1.3 白屏的性能优化

我们先梳理下白屏时间内发生了什么:

  1. 回车按下,浏览器解析网址,进行 DNS 查询,查询返回 IP,通过 IP 发出 HTTP(S) 请求
  2. 服务器返回HTML,浏览器开始解析 HTML,此时触发请求 js 和 css 资源
  3. js 被加载,开始执行 js,调用各种函数创建 DOM 并渲染到根节点,直到第一个可见元素产生
1.3.1 loading 提示

如果你用的是以 webpack 为基础的前端框架工程体系,那么你的index.html 文件一定是这样的:

    <div id="root"></div>

我们将打包好的整个代码都渲染到这个 root 根节点上,而我们如何渲染呢?当然是用 JavaScript 操作各种 dom 渲染,比如 react 肯定是调用各种 _React_._createElement_(),这是很耗时的,在此期间虽然 html 被加载了,但是依然是白屏,这就存在操作空间,我们能不能在 js 执行期间先加入提示,增加用户体验呢?

是的,我们一般有一款 webpack 插件叫html-webpack-plugin ,在其中配置 html 就可以在文件中插入 loading 图。

webpack 配置:

const HtmlWebpackPlugin = require('html-webpack-plugin')
const loading = require('./render-loading') // 事先设计好的 loading 图

module.exports = {
  entry: './src/index.js',
  output: {
    path: __dirname + '/dist',
    filename: 'index_bundle.js'
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
      loading: loading
    })
  ]
}
1.3.2 (伪)服务端渲染

那么既然在 HTML 加载到 js 执行期间会有时间等待,那么为什么不直接服务端渲染呢?直接返回的 HTML 就是带完整 DOM 结构的,省得还得调用 js 执行各种创建 dom 的工作,不仅如此还对 SEO 友好。

正是有这种需求 vue 和 react 都支持服务端渲染,而相关的框架Nuxt.js、Next.js也大行其道,当然对于已经采用客户端渲染的应用这个成本太高了。

于是有人想到了办法,谷歌开源了一个库Puppeteer,这个库其实是一个无头浏览器,通过这个无头浏览器我们能用代码模拟各种浏览器的操作,比如我们就可以用 node 将 html 保存为 pdf,可以在后端进行模拟点击、提交表单等操作,自然也可以模拟浏览器获取首屏的 HTML 结构。

prerender-spa-plugin就是基于以上原理的插件,此插件在本地模拟浏览器环境,预先执行我们的打包文件,这样通过解析就可以获取首屏的 HTML,在正常环境中,我们就可以返回预先解析好的 HTML 了。

1.3.3 开启 HTTP2

我们看到在获取 html 之后我们需要自上而下解析,在解析到 script 相关标签的时候才能请求相关资源,而且由于浏览器并发限制,我们最多一次性请求 6 次,那么有没有办法破解这些困境呢?

http2 是非常好的解决办法,http2 本身的机制就足够快:

  1. http2采用二进制分帧的方式进行通信,而 http1.x 是用文本,http2 的效率更高
  2. http2 可以进行多路复用,即跟同一个域名通信,仅需要一个 TCP 建立请求通道,请求与响应可以同时基于此通道进行双向通信,而 http1.x 每次请求需要建立 TCP,多次请求需要多次连接,还有并发限制,十分耗时

image.png

  1. http2 可以头部压缩,能够节省消息头占用的网络的流量,而HTTP/1.x每次请求,都会携带大量冗余头信息,浪费了很多带宽资源
例如:下图中的两个请求, 请求一发送了所有的头部字段,第二个请求则只需要发送差异数据,这样可以减少冗余数据,降低开销

image.png

  1. http2可以进行服务端推送,我们平时解析 HTML 后碰到相关标签才会进而请求 css 和 js 资源,而 http2 可以直接将相关资源直接推送,无需请求,这大大减少了多次请求的耗时

我们可以点击此网站 进行 http2 的测试

我曾经做个一个测试,http2 在网络通畅+高性能设备下的表现没有比 http1.1有明显的优势,但是网络越差,设备越差的情况下 http2 对加载的影响是质的,可以说 http2 是为移动 web 而生的,反而在光纤加持的高性能PC 上优势不太明显.
1.3.4 开启浏览器缓存

既然 http 请求如此麻烦,能不能我们避免 http 请求或者降低 http 请求的负载来实现性能优化呢?

利用浏览器缓存是很好的办法,他能最大程度上减少 http 请求,在此之前我们要先回顾一下 http 缓存的相关知识.

我们先罗列一下和缓存相关的请求响应头。

  • Expires
响应头,代表该资源的过期时间。
  • Cache-Control
请求/响应头,缓存控制字段,精确控制缓存策略。
  • If-Modified-Since
请求头,资源最近修改时间,由浏览器告诉服务器。
  • Last-Modified
响应头,资源最近修改时间,由服务器告诉浏览器。
  • Etag
响应头,资源标识,由服务器告诉浏览器。
  • If-None-Match
请求头,缓存资源标识,由浏览器告诉服务器。

配对使用的字段:

  • If-Modified-Since 和 Last-Modified
  • Etag 和 If-None-Match

当无本地缓存的时候是这样的:

当有本地缓存但没过期的时候是这样的:
image.png
当缓存过期了会进行协商缓存:
image.png
了解到了浏览器的基本缓存机制我们就好进行优化了.

通常情况下我们的 WebApp 是有我们的自身代码和第三方库组成的,我们自身的代码是会常常变动的,而第三方库除非有较大的版本升级,不然是不会变的,所以第三方库和我们的代码需要分开打包,我们可以给第三方库设置一个较长的强缓存时间,这样就不会频繁请求第三方库的代码了。

那么如何提取第三方库呢?在 webpack4.x 中, SplitChunksPlugin 插件取代了 CommonsChunkPlugin 插件来进行公共模块抽取,我们可以对SplitChunksPlugin 进行配置进行 拆包 操作。

SplitChunksPlugin配置示意如下:
optimization: {
    splitChunks: { 
      chunks: "initial",         // 代码块类型 必须三选一: "initial"(初始化) | "all"(默认就是all) | "async"(动态加载) 
      minSize: 0,                // 最小尺寸,默认0
      minChunks: 1,              // 最小 chunk ,默认1
      maxAsyncRequests: 1,       // 最大异步请求数, 默认1
      maxInitialRequests: 1,     // 最大初始化请求书,默认1
      name: () => {},            // 名称,此选项课接收 function
      cacheGroups: {                // 缓存组会继承splitChunks的配置,但是test、priorty和reuseExistingChunk只能用于配置缓存组。
        priority: "0",              // 缓存组优先级,即权重 false | object |
        vendor: {                   // key 为entry中定义的 入口名称
          chunks: "initial",        // 必须三选一: "initial"(初始化) | "all" | "async"(默认就是异步)
          test: /react|lodash/,     // 正则规则验证,如果符合就提取 chunk
          name: "vendor",           // 要缓存的 分隔出来的 chunk 名称
          minSize: 0,
          minChunks: 1,
          enforce: true,
          reuseExistingChunk: true   // 可设置是否重用已用chunk 不再创建新的chunk
        }
      }
    }
  }

SplitChunksPlugin 的配置项很多,可以先去官网了解如何配置,我们现在只简单列举了一下配置元素。

如果我们想抽取第三方库可以这样简单配置

   splitChunks: {
      chunks: 'all',   // initial、async和all
      minSize: 30000,   // 形成一个新代码块最小的体积
      maxAsyncRequests: 5,   // 按需加载时候最大的并行请求数
      maxInitialRequests: 3,   // 最大初始化请求数
      automaticNameDelimiter: '~',   // 打包分割符
      name: true,
      cacheGroups: {
        vendor: {
          name: "vendor",
          test: /[\\/]node_modules[\\/]/, //打包第三方库
          chunks: "all",
          priority: 10 // 优先级
        },
        common: { // 打包其余的的公共代码
          minChunks: 2, // 引入两次及以上被打包
          name: 'common', // 分离包的名字
          chunks: 'all',
          priority: 5
        },
      }
    },

这样似乎大功告成了?并没有,我们的配置有很大的问题:

  1. 我们粗暴得将第三方库一起打包可行吗? 当然是有问题的,因为将第三方库一块打包,只要有一个库我们升级或者引入一个新库,这个 chunk 就会变动,那么这个chunk 的变动性会很高,并不适合长期缓存,还有一点,我们要提高首页加载速度,第一要务是减少首页加载依赖的代码量,请问像 react vue reudx 这种整个应用的基础库我们是首页必须要依赖的之外,像 d3.js three.js这种特定页面才会出现的特殊库是没必要在首屏加载的,所以我们需要将应用基础库和特定依赖的库进行分离。
  2. 当 chunk 在强缓存期,但是服务器代码已经变动了我们怎么通知客户端?上面我们的示意图已经看到了,当命中的资源在缓存期内,浏览器是直接读取缓存而不会向服务器确认的,如果这个时候服务器代码已经变动了,怎么办?这个时候我们不能将 index.html 缓存(反正webpack时代的 html 页面小到没有缓存的必要),需要每次引入 script 脚本的时候去服务器更新,并开启 hashchunk,它的作用是当 chunk 发生改变的时候会生成新的 hash 值,如果不变就不发生变动,这样当 index 加载后续 script资源时如果 hashchunk 没变就会命中缓存,如果改变了那么会重新去服务端加载新资源。
下图示意了如何将第三方库进行拆包,基础型的 react 等库与工具性的 lodash 和特定库 Echarts 进行拆分
      cacheGroups: {
        reactBase: {
          name: 'reactBase',
          test: (module) => {
              return /react|redux/.test(module.context);
          },
          chunks: 'initial',
          priority: 10,
        },
        utilBase: {
          name: 'utilBase',
          test: (module) => {
              return /rxjs|lodash/.test(module.context);
          },
          chunks: 'initial',
          priority: 9,
        },
        uiBase: {
          name: 'chartBase',
          test: (module) => {
              return /echarts/.test(module.context);
          },
          chunks: 'initial',
          priority: 8,
        },
        commons: {
          name: 'common',
          chunks: 'initial',
          priority: 2,
          minChunks: 2,
        },
      }
我们对 chunk 进行 hash 化,正如下图所示,我们变动 chunk2 相关的代码后,其它 chunk 都没有变化,只有 chunk2 的 hash 改变了
  output: {
    filename: mode === 'production' ? '[name].[chunkhash:8].js' : '[name].js',
    chunkFilename: mode === 'production' ? '[id].[chunkhash:8].chunk.js' : '[id].js',
    path: getPath(config.outputPath)
  }

image.pngimage.png

我们通过 http 缓存+webpack hash 缓存策略使得前端项目充分利用了缓存的优势,但是 webpack 之所以需要传说中的 webpack配置工程师 是有原因的,因为 webpack 本身是玄学,还是以上图为例,如果你 chunk2的相关代码去除了一个依赖或者引入了新的但是已经存在工程中依赖,会怎么样呢?

我们正常的期望是,只有 chunk2 发生变化了,但是事实上是大量不相干的 chunk 的 hash 发生了变动,这就导致我们缓存策略失效了,下图是变更后的 hash,我们用红圈圈起来的都是 hash 变动的,而事实上我们只变动了 chunk2 相关的代码,为什么会这样呢?

image.png
原因是 webpack 会给每个 chunk 搭上 id,这个 id 是自增的,比如 chunk 0 中的id 为 0,一旦我们引入新的依赖,chunk 的自增会被打乱,这个时候又因为 hashchunk 根据内容生成 hash,这就导致了 id 的变动致使 hashchunk 发生巨变,虽然代码内容根本没有变化。image.png
这个问题我们需要额外引入一个插件HashedModuleIdsPlugin,他用非自增的方式进行 chunk id 的命名,可以解决这个问题,虽然 webpack 号称 0 配置了,但是这个常用功能没有内置,要等到下个版本了。
image.png

webpack hash缓存相关内容建议阅读此文章 作为拓展

1.4 FMP(首次有意义绘制)

在白屏结束之后,页面开始渲染,但是此时的页面还只是出现个别无意义的元素,比如下拉菜单按钮、或者乱序的元素、导航等等,这些元素虽然是页面的组成部分但是没有意义.
什么是有意义?
对于搜索引擎用户是完整搜索结果
对于微博用户是时间线上的微博内容
对于淘宝用户是商品页面的展示

那么在FCP 和 FMP 之间虽然开始绘制页面,但是整个页面是没有意义的,用户依然在焦虑等待,而且这个时候可能出现乱序的元素或者闪烁的元素,很影响体验,此时我们可能需要进行用户体验上的一些优化。
Skeleton是一个好方法,Skeleton现在已经很开始被广泛应用了,它的意义在于事先撑开即将渲染的元素,避免闪屏,同时提示用户这要渲染东西了,较少用户焦虑。

比如微博的Skeleton就做的很不错

image.png
在不同框架上都有相应的Skeleton实现
React: antd 内置的骨架图Skeleton方案
Vue: vue-skeleton-webpack-plugin 

以 vue-cli 3 为例,我们可以直接在vue.config.js 中配置
//引入插件
const SkeletonWebpackPlugin = require('vue-skeleton-webpack-plugin');

module.exports = {
    // 额外配置参考官方文档
    configureWebpack: (config)=>{
        config.plugins.push(new SkeletonWebpackPlugin({
            webpackConfig: {
                entry: {
                    app: path.join(__dirname, './src/Skeleton.js'),
                },
            },
            minimize: true,
            quiet: true,
        }))
    },
    //这个是让骨架屏的css分离,直接作为内联style处理到html里,提高载入速度
    css: {
        extract: true,
        sourceMap: false,
        modules: false
    }
}

然后就是基本的 vue 文件编写了,直接看文档即可。

1.5 TTI(可交互时间)

当有意义的内容渲染出来之后,用户会尝试与页面交互,这个时候页面并不是加载完毕了,而是看起来页面加载完毕了,事实上这个时候 JavaScript 脚本依然在密集得执行.

我们看到在页面已经基本呈现的情况下,依然有大量的脚本在执行

image.png
这个时候页面并不是可交互的,直到TTI 的到来,TTI 到来之后用户就可以跟页面进行正常交互的,TTI 一般没有特别精确的测量方法,普遍认为满足FMP && DOMContentLoader事件触发 && 页面视觉加载85%这几个条件后,TTI 就算是到来了。

在页面基本呈现到可以交互这段时间,绝大部分的性能消耗都在 JavaScript 的解释和执行上,这个时候决定 JavaScript 解析速度的无非一下两点:

  1. JavaScript 脚本体积
  2. JavaScript 本身执行速度

JavaScript 的体积问题我们上一节交代过了一些,我们可以用SplitChunksPlugin拆库的方法减小体积,除此之外还有一些方法,我们下文会交代。

1.5.1 Tree Shaking

Tree Shaking虽然出现很早了,比如js基础库的事实标准打包工具 rollup 就是Tree Shaking的祖师爷,react用 rollup 打包之后体积减少了 30%,这就是Tree Shaking的厉害之处。

Tree Shaking的作用就是,通过程序流分析找出你代码中无用的代码并剔除,如果不用Tree Shaking那么很多代码虽然定义了但是永远都不会用到,也会进入用户的客户端执行,这无疑是性能的杀手,Tree Shaking依赖es6的module模块的静态特性,通过分析剔除无用代码.

目前在 webpack4.x 版本之后在生产环境下已经默认支持Tree Shaking了,所以Tree Shaking可以称得上开箱即用的技术了,但是并不代表Tree Shaking真的会起作用,因为这里面还是有很多坑.

坑 1: Babel 转译,我们已经提到用Tree Shaking的时候必须用 es6 的module,如果用 common.js那种动态module,Tree Shaking就失效了,但是 Babel 默认状态下是启用 common.js的,所以需要我们手动关闭.
坑 2: 第三方库不可控,我们已经知道Tree Shaking的程序分析依赖 ESM,但是市面上很多库为了兼容性依然只暴露出了ES5 版本的代码,这导致Tree Shaking对很多第三方库是无效的,所以我们要尽量依赖有 ESM 的库,比如之前有一个 ESM 版的 lodash(lodash-es),我们就可以这样引用了import { dobounce } from 'lodash-es'

1.5.2 polyfill动态加载

polyfill是为了浏览器兼容性而生,是否需要 polyfill 应该有客户端的浏览器自己决定,而不是开发者决定,但是我们在很长一段时间里都是开发者将各种 polyfill 打包,其实很多情况下导致用户加载了根本没有必要的代码.

解决这个问题的方法很简单,直接引入 <script data-original="https://cdn.polyfill.io/v2/polyfill.min.js"></script> 即可,而对于 Vue 开发者就更友好了,vue-cli 现在生成的模板就自带这个引用.
image.png

这个原理就是服务商通过识别不同浏览器的浏览器User Agent,使得服务器能够识别客户使用的操作系统及版本、CPU 类型、浏览器及版本、浏览器渲染引擎、浏览器语言、浏览器插件等,然后根据这个信息判断是否需要加载 polyfill,开发者在浏览器的 network 就可以查看User Agent。
image.png

1.5.3 动态加载 ES6 代码

既然 polyfill 能动态加载,那么 es5 和 es6+的代码能不能动态加载呢?是的,但是这样有什么意义呢?es6 会更快吗?

我们得首先明确一点,一般情况下在新标准发布后,浏览器厂商会着重优化新标准的性能,而老的标准的性能优化会逐渐停滞,即使面向未来编程,es6 的性能也会往越来越快的方向发展.
其次,我们平时编写的代码可都es6+,而发布的es5是经过babel 或者 ts 转译的,在大多数情况下,经过工具转译的代码往往被比不上手写代码的性能,这个性能对比网站 的显示也是如此,虽然 babel 等转译工具都在进步,但是仍然会看到转译后代码的性能下降,尤其是对 class 代码的转译,其性能下降是很明显的.
最后,转译后的代码体积会出现代码膨胀的情况,转译器用了很多奇技淫巧将 es6 转为 es5 导致了代码量剧增,使用 es6就代表了更小的体积.

那么如何动态加载 es6 代码呢?秘诀就是<script type="module">这个标签来判断浏览器是否支持 es6,我之前在掘金上看到了一篇翻译的文章 有详细的动态打包过程,可以拓展阅读.

体积大小对比

image.png

执行时间对比

image.png
双方对比的结果是,es6 的代码体积在小了一倍的同时,性能高出一倍.

1.5.4 路由级别拆解代码

我们在上文中已经通过SplitChunksPlugin将第三方库进行了抽离,但是在首屏加载过程中依然有很多冗余代码,比如我们的首页是个登录界面,那么其实用到的代码很简单

  1. 框架的基础库例如 vue redux 等等
  2. ui 框架的部分 form 组件和按钮组件等等
  3. 一个简单的布局组件
  4. 其它少量逻辑和样式

登录界面的代码是很少的,为什么不只加载登录界面的代码呢?
这就需要我们进行对代码在路由级别的拆分,除了基础的框架和 UI 库之外,我们只需要加载当前页面的代码即可,这就有得用到Code Splitting技术进行代码分割,我们要做的其实很简单.
我们得先给 babel 设置plugin-syntax-dynamic-import这个动态import 的插件,然后就可以就函数体内使用 import 了.

对于Vue 你可以这样引入路由

export default new Router({ 
  routes: [ 
  { 
  path: '/', 
  name: 'Home', 
  component: Home 
  }, 
  { 
  path: '/login', 
  name: 'login', 
  component: () => import('@components/login') 
  } 
  ]

你的登录页面会被单独打包.
对于react,其内置的 React.lazy() 就可以动态加载路由和组件,效果与 vue 大同小异,当然 lazy() 目前还没有支持服务端渲染,如果想在服务端渲染使用,可以用React Loadable.

2 组件加载

路由其实是一个大组件,很多时候人们忽略了路由跳转之间的加载优化,更多的时候我们的精力都留在首屏加载之上,但是路由跳转间的加载同样重要,如果加载过慢同样影响用户体验。

我们不可忽视的是在很多时候,首屏的加载反而比路由跳转要快,也更容易优化。

比如石墨文档的首页是这样的:
image.png
一个非常常见的官网首页,代码量也不会太多,处理好第三方资源的加载后,是很容易就达到性能要求的页面类型.
加载过程不过几秒钟,而当我跳转到真正的工作界面时,这是个类似 word 的在线编辑器
image.png
我用 Lighthouse 的测试结果是,可交互时间高达17.2s
image.png

这并不是石墨做得不够好,而是对于这种应用型网站,相比于首屏,工作页面的跳转加载优化难度更大,因为其工作页面的代码量远远大于一个官网的代码量和复杂度.

我们看到在加载过程中有超过 6000ms 再进行 JavaScript 的解析和执行

image.png

2.1 组件懒加载

Code Splitting不仅可以进行路由分割,甚至可以进行组件级别的代码分割,当然是用方式也是大同小异,组件的级别的分割带来的好处是我们可以在页面的加载中只渲染部分必须的组件,而其余的组件可以按需加载.

就比如一个Dropdown(下拉组件),我们在渲染初始页面的时候下拉的Menu(菜单组件)是没必要渲染的,因为只有点击了Dropdown之后Menu 才有必要渲染出来.

路由分割 vs 组件分割

image.png
我们可以以一个demo 为例来分析一下组件级别分割的方法与技巧.
我们假设一个场景,比如我们在做一个打卡应用,有一个需求是我们点击下拉菜单选择相关的习惯,查看近一周的打卡情况.

我们的 demo 是这样子:

QQ20190611-105233.gif

我们先对比一下有组件分割和无组件分割的资源加载情况(开发环境下无压缩)

无组件分割,我们看到有一个非常大的chunk,因为这个组件除了我们的代码外,还包含了 antd 组件和 Echarts 图表以及 React 框架部分代码

image.png

组件分割后,初始页面体积下降明显,路由间跳转的初始页面加载体积变小意味着更快的加载速度

image.png

其实组件分割的方法跟路由分割差不多,也是通过 lazy + Suspense 的方法进行组件懒加载

// 动态加载图表组件
const Chart = lazy(() => import(/* webpackChunkName: 'chart' */'./charts'))

// 包含着图表的 modal 组件
const ModalEchart = (props) => (
    <Modal
    title="Basic Modal"
    visible={props.visible}
    onOk={props.handleOk}
    onCancel={props.handleCancel}
  >
      <Chart />
  </Modal>
)

2.2 组件预加载

我们通过组件懒加载将页面的初始渲染的资源体积降低了下来,提高了加载性能,但是组件的性能又出现了问题,还是上一个 demo,我们把初始页面的 3.9m 的体积减少到了1.7m,页面的加载是迅速了,但是组件的加载却变慢了.

原因是其余的 2m 资源的压力全部压在了图表组件上(Echarts 的体积缘故),因此当我们点击菜单加载图表的时候会出现 1-2s 的 loading 延迟,如下:

image.png

我们能不能提前把图表加载进来,避免图表渲染中加载时间过长的问题?这种提前加载的方法就是组件的预加载.

原理也很简单,就是在用户的鼠标还处于 hover 状态的时候就开始触发图表资源的加载,通常情况下当用户点击结束之后,加载也基本完成,这个时候图表会很顺利地渲染出来,不会出现延迟.

/**
 * @param {*} factory 懒加载的组件
 * @param {*} next factory组件下面需要预加载的组件
 */
function lazyWithPreload(factory, next) {
  const Component = lazy(factory);
  Component.preload = next;
  return Component;
}
...
// 然后在组件的方法中触发预加载
  const preloadChart = () => {
    Modal.preload()
  }

demo地址

2.3 keep-alive

对于使用 vue 的开发者 keep-alive 这个 API 应该是最熟悉不过了,keep-alive 的作用是在页面已经跳转后依然不销毁组件,保存组件对应的实例在内存中,当此页面再次需要渲染的时候就可以利用已经缓存的组件实例了。

如果大量实例不销毁保存在内存中,那么这个 API 存在内存泄漏的风险,所以要注意调用deactivated销毁

但是在 React 中并没有对应的实现,而官方 issue 中官方也明确不会添加类似的 API,但是给出了两个自行实现的方法

  1. 利用全局状态管理工具例如 redux 进行状态缓存
  2. 利用 style={{display: 'none'}} 进行控制

如果你看了这两个建议就知道不靠谱,redux 已经足够啰嗦了,我们为了缓存状态而利用 redux 这种全局方案,其额外的工作量和复杂度提升是得不偿失的,用 dispaly 控制显示是个很简单的方法,但是也足够粗暴,我们会损失很多可操作的空间,比如动画。

react-keep-alive 在一定程度上解决这个问题,它的原理是利用React 的 Portals API 将缓存组件挂载到根节点以外的 dom 上,在需要恢复的时候再将缓存组件挂在到相应节点上,同时也可以在额外的生命周期 componentWillUnactivate 进行销毁处理。


小结

当然还有很多常见的性能优化方案我们没有提及:

  1. 图片懒加载方案,这是史前前端就开始用的技术,在 JQuery 或者各种框架都有成熟方案
  2. 资源压缩,现在基本上用反向代理工具都是自动开启的
  3. cdn,已经见不到几个web 产品不用 cdn 了,尤其是云计算厂商崛起后 cdn 很便宜了
  4. 域名收敛或者域名发散,这种情况在 http2 使用之后意义有限,因为一个域名可以直接建立双向通道多路复用了
  5. 雪碧图,很古老的技术了,http2 使用后也是效果有限了
  6. css 放头,js 放最后,这种方式适合工程化之前,现在基本都用打包工具代替了
  7. 其它...

我们着重整理了前端加载阶段的性能优化方案,很多时候只是给出了方向,真正要进行优化还是需要在实际项目中根据具体情况进行分析挖掘才能将性能优化做到最好.


参考链接:

  1. 性能指标的参考
  2. Tree Shaking原理
  3. 组件预加载
  4. http2
  5. 部署 es6
  6. Tree-Shaking性能优化实践
  7. 缓存策略

公众号

想要实时关注笔者最新的文章和最新的文档更新请关注公众号程序员面试官,后续的文章会优先在公众号更新.

简历模板: 关注公众号回复「模板」获取

《前端面试手册》: 配套于本指南的突击手册,关注公众号回复「fed」获取

2019-08-12-03-18-41

本文由博客一文多发平台 OpenWrite 发布!
查看原文

汪雪来 关注了专栏 · 2018-12-10

终身学习者

我要先坚持分享20年,大家来一起见证吧。

关注 40750

汪雪来 关注了专栏 · 2018-12-10

腾讯云技术社区

最专业的云解读社区

关注 11482

汪雪来 关注了用户 · 2018-12-10

阿里云云栖号 @yunqishequ_5aa899aad5395

阿里云官网内容平台!汇聚阿里云优质内容(入门、文档、案例、最佳实践、直播等)!如需转载或内容类合作,邮件yqgroup@service.aliyun.com 秒级回复!

关注 10559

汪雪来 关注了专栏 · 2018-12-10

前端修炼之路

每天多学一点,幸福多攒一点。

关注 533

汪雪来 关注了专栏 · 2018-12-10

美团技术团队

“美团技术团队”,你最值得关注的技术团队官方微信公众号。 每周会推送来自一线的实践技术文章,涵盖前端(Web、iOS和Android)、后台、大数据、AI/算法、测试、运维等技术领域。 在这里,与8000多业界一流工程师交流切磋。

关注 10130

汪雪来 关注了标签 · 2018-12-10

前端

Web前端开发是从网页制作演变而来的,名称上有很明显的时代特征。在互联网的演化进程中,网页制作是Web 1.0时代的产物,那时网站的主要内容都是静态的,用户使用网站的行为也以浏览为主。2005年以后,互联网进入Web 2.0时代,各种类似桌面软件的Web应用大量涌现,网站的前端由此发生了翻天覆地的变化。网页不再只是承载单一的文字和图片,各种富媒体让网页的内容更加生动,网页上软件化的交互形式为用户提供了更好的使用体验,这些都是基于前端技术实现的。

Web前端优化
  1. 尽量减少HTTP请求 (Make Fewer HTTP Requests)
  2. 减少 DNS 查找 (Reduce DNS Lookups)
  3. 避免重定向 (Avoid Redirects)
  4. 使得 Ajax 可缓存 (Make Ajax Cacheable)
  5. 延迟载入组件 (Post-load Components)
  6. 预载入组件 (Preload Components)
  7. 减少 DOM 元素数量 (Reduce the Number of DOM Elements)
  8. 切分组件到多个域 (Split Components Across Domains)
  9. 最小化 iframe 的数量 (Minimize the Number of iframes)
  10. 杜绝 http 404 错误 (No 404s)

关注 152566

认证与成就

  • 获得 0 次点赞
  • 获得 0 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 0 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-12-11
个人主页被 37 人浏览