前言随着响应式编程的理念&Flutter被大众所了解以来,状态管理一直是一个引人深思的话题。如果想要学习好Flutter这样的响应式的编程框架就一定是离不开状态管理的。我遇到过很多没有了解过响应式编程框架的,或者从事后端开发,自己想用Flutter写个app玩玩的朋友,一上来,不管在哪里都用setState,我问为啥不用状态管理,大部分都回了一句:啥是状态管理?当然,曾经的我也一样,啥也不懂,一上来就用上了GetX这个“大杀器”从而导致走了许多弯路。ps:都2023年了,已经有那么多的大佬写了与状态管理相关的文章,为啥我还要“炒冷饭”?因为一句话:GetX你害得我好惨!同样,不同的人对于状态管理有不同的看法,一千个读者眼中就会有一千个哈姆雷特。我希望能通过几篇文章来帮助和我类似经历的朋友,更好的明白,啥是状态管理。需要我们管理的状态有哪些在一个应用中,存在着大量的状态,例如某个组件的动画状态、界面的外观效果、字体...当然,很多的状态并不需要我们自己去做管理,框架本身就已经做了这部分的工作了,例如:将Widget树转换为底层的图像或纹理、无需手动跟踪和管理动画状态,以及处理布局,包括大小、位置和约束,无需手动计算和管理布局状态。这才可以让我们开发者更专注于构建用户界面和交互逻辑,而无需过多关注底层的状态细节。而需要我们开发者去管理的状态可以分为两类:短时状态你可以简单的将其理解为:某些数据状态只需要在当前的Widget中使用,不需要将这些数据和状态共享给其他组件或者页面。通常这样的情况,你不需要用到一些provider、GetX这样的状态管理框架,你仅仅需要一个StatefulWidget,依靠其自身的State管理即可,这种状态也不会以复杂的方式而改变。像一个提示消息状态(Toast)、页面切换时的一些动画(淡入淡出效果)、动画状态(一个淡入淡出的动画或一个旋转动画都是短时状态)。class _MyHomePageState extends State<MyHomePage> { Color _buttonColor = Colors.blue; // 初始颜色 void _changeButtonColor() { setState(() { _buttonColor = Colors.green; // 更新颜色为绿色 }); // 等待1秒后恢复原样 Future.delayed(Duration(seconds: 1), () { setState(() { _buttonColor = Colors.blue; // 恢复原样 }); }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('短时状态'), ), body: Center( child: ElevatedButton( onPressed: _changeButtonColor, style: ElevatedButton.styleFrom( backgroundColor: _buttonColor, // 使用当前状态中的颜色 padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10), ), child: const Text( 'Change Color', style: TextStyle(fontSize: 18), ), ), ), ); } } 当用户点击 "Change Color" 按钮时,按钮的颜色会在一秒钟内变为绿色,然后恢复为蓝色。这里的按钮颜色变化过程就是一个短时状态,因为它只在特定的时间段内有效,不需要长期存储,也不需要共享。应用状态当数据或状态需要被共享时,就是当一个组件的状态发生变化后,其他的组件也会跟着发生变化,这就是应用状态。建议通过状态管理库(如Provider)来管理。应用状态的生命周期更长,通常会影响应用全局行为,例如用户身份、主题、国际化等。状态提升在使用状态管理之前,我们还需学习下状态提升,它能使我们更好的理解到状态管理的作用!话不多说,直接上案例:可以从图中发现,第一个页面的值与第二个页面的值是不同步的。在很多业务场景下,都需要将二级的详情页面的值与一级页面的值同步,例如文章的点赞数。那么这个时候会有一种简单的方法,就是将这个值提取到父级页面,由父级页面把状态传递下去。我们抽离出一个Count类,并在父级界面实例化一个Count类,通过这种方式,第一个页面和第二个页面将共享同一个计数器对象,因此无论在哪个页面中增加计数器的值,都会同步地影响另一个页面中的计数器。这就实现了状态的提升和同步。不过,我们虽然通过状态提升的方法做到了状态同步这一点,但是,仅仅是一个Count数字的改变,却需要将第一个页面和第二个页面上的所有组件进行重新build,简单的页面当然看不出什么,一旦页面复杂,渲染的成本实在是太高,也违背了我们使用状态管理的一个初衷:控制组件局部刷新。为了能更好的解决这样的问题,我们需要探索新的方法,这时,状态管理就起到作用了!Flutter中有哪些可以做到状态管理1.setState我相信无论是新手还是老手,只要是体验过Flutter框架99%的朋友都知道setState,大部分情况只需要在改变UI数据的逻辑外面套上一个setState就可以实现对界面的更新。在本文中我们不过多讨论setState的使用方式以及原理,我们聊聊它的优点和缺点。优点相信大家在各种Flutter交流群中应该都有遇到过群友问性能相关的问题。而一些新手朋友这个时候看的多了就会有一个疑惑:我写的一些页面也卡卡的,帧率只有30几帧,会不会是因为我使用了setState去刷新页面啊?其实不然,setState不但不会造成性能困扰,它反而在帮你!最关键的一点,是因为Flutter在底层实现了一些机制来减少不必要的重绘。具体一点来说,当去调用setState时,Flutter会将新的状态放入队列中,并计划在下一个绘制周期(Frame)中更新UI。在下一个绘制周期到来之前,Flutter会执行一些优化步骤:差异校验:Flutter会比较前后两个状态,确定实际上哪些部分需要更新。这种差异校验能够有效地减少不必要的布局、绘制和合成操作。例如: 合并多个setState调用:如果在同一个绘制周期内连续调用了多次setState,Flutter会将它们合并成一个,以避免不必要的重复工作。setState只是调用了_element的markNeedsBuild方法,以标记这个元素需要在下一个绘制周期中进行重建。这个标记会将新的状态放入队列中,并在下一个帧(绘制周期)中触发重建。 重绘策略:Flutter会尽量减少需要重新绘制的部分。如果某个部分没有发生变化,那么不会重新绘制它,从而节省CPU和GPU资源。 将组件单独抽离为一个widget,就可以通过setState来更新局部状态,实现局部刷新。所以,只要能正确的使用setState,setState的节点越远离根部,那么布局、绘制渲染的开销就会越少。(使用绝对宽高也可以减少开销哦~)缺点聊完了setState的优点,那么我们再来聊聊它的缺点:**无法做到跨组件共享数据 **setState是State的函数,一般我们会将State的子类设置为私有,所以无法做到让别的组件调用State的setState函数来刷新。 维护成本极高 在文章的开头,就提到过,一些新手写Flutter时,不管在哪里都用setState,搞的哪哪都是。随着页面状态的增多,调用setState的地方会越来越多,不能统一管理。难以维护。 **状态和UI耦合 **使用状态管理很重要的一点就是解耦,而随意使用setState会导致数据逻辑和视图混合在一起,比如数据库的数据取出来setState到ui上,这样编写代码,会导致状态和UI耦合在一起,不利于测试,不利于复用。 实现局部刷新复杂度高 setState是整个Widget重新构建(子Widget也会跟着销毁重建),如果页面足够复杂,就会导致非常严重的性能损耗。而如果想要通过setState进行局部刷新,就需要对组件进行提取,如果每个组件都要封装提取一下,这个工作量太多了!2.ChangeNotifier为了解决setState带来的一些问题,可以通过ChangeNotifier作为状态管理的方案。 ChangeNotifier可以将状态逻辑和UI逻辑分开,从而使得状态管理更加集中和可控。通过创建一个单独的ChangeNotifier类来管理某一部分状态,然后在需要使用这些状态的地方进行订阅。class CounterModel extends ChangeNotifier { int _counter = 0; int get counter => _counter; void increment() { _counter++; notifyListeners(); } } class MyApp extends StatelessWidget { final CounterModel _counterModel = CounterModel(); @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( appBar: AppBar(title: Text('ChangeNotifier Example')), body: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CounterDisplay(_counterModel), CounterControl(_counterModel), ], ), ), ); } } class CounterDisplay extends StatefulWidget { final CounterModel counterModel; CounterDisplay(this.counterModel); @override _CounterDisplayState createState() => _CounterDisplayState(); } class _CounterDisplayState extends State<CounterDisplay> { @override void initState() { super.initState(); widget.counterModel.addListener(_update); } @override void dispose() { widget.counterModel.removeListener(_update); super.dispose(); } void _update() { setState(() {}); } @override Widget build(BuildContext context) { return Center( child: Text('Value: ${widget.counterModel.counter}'), ); } } class CounterControl extends StatelessWidget { final CounterModel counterModel; const CounterControl(this.counterModel, {Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Center( child: ElevatedButton( onPressed: () { counterModel.increment(); }, child: Text('+'), ), ); } } 不过,虽然ChangeNotifier解决了一些问题,但在大型应用中,仍然可能遇到一些挑战。例如,当应用状态较为复杂时,可能需要多个ChangeNotifier,从而导致状态分散和层级复杂。3.ChangeNotifier+InheritedWidget在前面我们提到可以通过状态提升的方法,实现跨组件之间的数据传递。但是一些简单的小项目都会有很多很多的组件,组件之间的继承关系会很复杂,如果只是使用状态提升,然后通过传参和回调函数这样的方式,那么你会发现,在继承关系中间的一些组件需要传入大量的参数,相当麻烦!那怎么让底层的子组件直接去访问被我们提升到父级的状态呢?Flutter已经帮我们解决了这个问题,就是使用InheritedWidget,它可以高效快捷的实现共享数据的跨组件传递。一些初学者可能会觉得InheritedWidget很陌生,但是,你一定使用过InheritedWidget传递状态的场景:Theme.of(context).primaryColor //获取主题色 MediaQuery.of(context).size.width; // 获取屏幕宽度 点击他们的实现源码就可以看到使用了context.dependOnInheritedWidgetOfExactType。使用inheritedWidget的时候,会有一个取数据的过程,这个时候就会通过子节点的BuildContext使用context.getElementForInheritedWidgetOfExactType 或 context.dependOnInheritedWidgetOfExactType来获取到这个widget(element),从而获取到数据。InheritedWidget通常用于子组件共享父组件中的数据,但它不具备修改更新父组件中数据的能力。它解决了访问状态和根据状态更新的问题,但是没有改变状态的能力,所以通常会把InheritedWidget和ChangeNotifier结合一起使用,通过ChangeNotifier去跟踪变化的数据。class CounterModel extends ChangeNotifier { int _counter = 0; int get counter => _counter; void increment() { _counter++; notifyListeners(); // Notify listeners when the state changes } } class CounterProvider extends InheritedWidget { final CounterModel counterModel; final Widget child; CounterProvider({required this.counterModel, required this.child}) : super(child: child); static CounterProvider? of(BuildContext context) { return context.dependOnInheritedWidgetOfExactType<CounterProvider>(); } @override bool updateShouldNotify(CounterProvider oldWidget) { return counterModel != oldWidget.counterModel; } } class MyApp extends StatelessWidget { final count = CounterModel(); @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( appBar: AppBar(title: Text('ChangeNotifier + InheritedWidget')), body: CounterProvider( counterModel: count, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CounterDisplay(), CounterControl(), ], )), ), ); } } class CounterDisplay extends StatefulWidget { @override _CounterDisplayState createState() => _CounterDisplayState(); } class _CounterDisplayState extends State<CounterDisplay> { late CounterModel counterModel; late VoidCallback listener; @override void didChangeDependencies() { super.didChangeDependencies(); counterModel = CounterProvider.of(context)!.counterModel; listener = () { if (mounted) setState(() {}); }; counterModel.addListener(listener); } @override void dispose() { counterModel.removeListener(listener); super.dispose(); } @override Widget build(BuildContext context) { return Center( child: Text('Counter Value: ${counterModel.counter}'), ); } } class CounterControl extends StatelessWidget { @override Widget build(BuildContext context) { final counterModel = CounterProvider.of(context)!.counterModel; return Center( child: GestureDetector( onTap: () { counterModel.increment(); }, child: Text('+'), ), ); } } 通过这样一个简单的例子来体验InheritedWidget结合ChangeNotifier实现状态管理的功能。不过InheritedWidget也存在缺点,InheritedWidget的通知机制是基于它的数据发生变化时触发,而不是针对特定的子小部件,无法定向通知,这可能导致不必要的刷新,尤其是在大型小部件树中。它会触发整个子小部件树的重建,即使只有一部分子小部件受到了影响,这也有可能导致性能问题,尤其是在需要精细控制刷新的情况下。当然,在后续的文章中,我们也会详细分析InheritedWidget的实现机制,来帮助更好的理解整套流程。同时,考虑到这些缺点,在后续的文章也会分析更好的状态管理解决方案。4.NotificationNotification是一种用于在小部件树中传递信息的机制,它可以用于实现子树中的特定部分之间的通信。Notification并不像状态管理或全局状态传递那样普遍,它主要用于特定场景下的通信,比如当某个事件发生时,需要在小部件树的各个部分之间传递消息。Notification的工作方式是通过Notification对象在小部件树中传递,然后从父级小部件开始逐级向上冒泡,直到找到一个处理该通知的小部件为止。每个处理通知的小部件可以根据需要执行特定的操作。你可以把InheritedWidget 理解为从上到下传递、共享的方式,而Notification则是从下往上。Notification它提供了dispatch方法,沿着context对应的Element节点向上逐层发送通知。class MyNotification extends Notification { final String message; MyNotification(this.message); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( appBar: AppBar(title: Text('Notification')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ NotificationListener<MyNotification>( onNotification: (notification) { print(notification.message); return true; }, child: ChildWidget(), ), ], ), ), ), ); } } class ChildWidget extends StatelessWidget { @override Widget build(BuildContext context) { return ElevatedButton( onPressed: () { MyNotification('Hello 这里是子Widget!').dispatch(context); }, child: Text('ChildWidget'), ); } } 当点击按钮后,就会向上传递消息,被NotificationListener所监听,在控制台打印出消息。你可以尝试InheritedWidget + Notification来控制状态之间的访问。5.StreamStream是一种用于在应用程序中管理状态和数据流的重要工具。Stream是异步数据流的抽象表示,它可以在应用程序中传递和监听数据的变化。但是它和Flutter关系并不大,它是通过纯dart去实现的。你可以理解为flutter只是通过StreamBuilder去构建了一个Stream通道。它的使用其实也并没有复杂太多,通常只需要创建StreamController,然后去监听控制器(可以直接去监听StreamController,然后通过setState更新UI,也可以通过StreamBuilder),最后将更新后的数据通过Stream的sink属性添加到Stream中即可。知名的状态管理库Bloc,就是基于Stream的封装。class Todo { final String text; bool isCompleted; Todo(this.text, this.isCompleted); } class _TodoAppState extends State<TodoApp> { final _controller = StreamController<List<Todo>>(); List<Todo> _todos = []; @override void initState() { super.initState(); // 初始化Stream,将空列表添加到Stream中 _controller.sink.add(_todos); } @override void dispose() { _controller.close(); // 关闭StreamController以释放资源 super.dispose(); } void _addTodo(String text) { // 添加新的Todo项并将更新后的列表添加到Stream中 final newTodo = Todo(text, false); _todos.add(newTodo); _controller.sink.add(_todos); } void _toggleTodoCompletion(int index) { // 切换指定Todo项的完成状态并将更新后的列表添加到Stream中 _todos[index].isCompleted = !_todos[index].isCompleted; _controller.sink.add(_todos); } void _deleteTodo(int index) { // 删除指定Todo项并将更新后的列表添加到Stream中 _todos.removeAt(index); _controller.sink.add(_todos); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('备忘录 —— Stream'), ), body: Column( children: [ Padding( padding: EdgeInsets.all(16.0), child: TextField( onSubmitted: (text) { if (text.isNotEmpty) { _addTodo(text); } }, decoration: InputDecoration( labelText: '添加任务', border: OutlineInputBorder(), ), ), ), Expanded( child: StreamBuilder<List<Todo>>( stream: _controller.stream, builder: (context, snapshot) { if (snapshot.hasData) { return ListView.builder( itemCount: snapshot.data!.length, itemBuilder: (context, index) { final todo = snapshot.data![index]; return ListTile( title: Text(todo.text), trailing: Checkbox( value: todo.isCompleted, onChanged: (_) => _toggleTodoCompletion(index), ), onLongPress: () => _deleteTodo(index), ); }, ); } else { return Center(child: CircularProgressIndicator()); } }, ), ), ], ), ); } } Stream的缺点也同样是它的优点:缺点:需要对其定制化,才能满足更复杂的场景。优点:可以针对业务进行定制化,足够灵活,可以基于它做一个好的设计。总结看完这篇文章后,相信您对Flutter中的状态应该有了一个更深刻的记忆。对Flutter自带的状态管理的方式也有了一定的了解,那么在后续的文章中,我们将进一步深入学习ChangeNotifier+InheritedWidget的一些机制,以及主流的状态管理框架。在文章的最后,我想引用一下flutter.cn的一句话:状态管理是一个相当复杂的话题。如果您在浏览后发现一些问题并未得到解答,或者并不适用于您的具体需求场景,自信些,您的实现就是对的。
一.进程与线程进程是操作系统资源分配的基本单位,进程中包含线程。线程是由进程所管理的。为了提升浏览器的稳定性和安全性,浏览器采用了多进程模型。浏览器中的(5个)进程浏览器进程:负责界面显示、用户交互、子进程管理,提供存储等。渲染进程:每个也卡都有单独的渲染进程,核心用于渲染页面。网络进程:主要处理网络资源加载(HTML、CSS、JS等)GPU进程:3d绘制,提高性能插件进程: chrome中安装的一些插件二.从输入URL到浏览器显示页面发生了什么?用户输入的是关键字还是URL? 如果是关键字则使用默认搜索引擎生产URL1.浏览器进程的相互调用在浏览器进程中输入url地址开始导航。并准备渲染进程在网络进程中发送请求,将响应后的结果交给渲染进程处理解析页面,加载页面中所需资源渲染完毕,展示结果我们开始细化每一步流程,并且从流程中提取我们可以优化的点。2.URL请求过程浏览器查找当前URL是否存在缓存,如果有缓存、并且缓存未过期,直接从缓存中返回。查看域名是否已经被解析过了,没有解析过进行DNS解析将域名解析成IP地址,并增加端口号如果请求是HTTPS,进行SSL协商利用IP地址进行寻址,请求排队。同一个域名下请求数量不能多余6个。排队后服务器创建TCP链接 (三次握手)利用TCP协议将大文件拆分成数据包进行传输(有序传输),可靠的传输给服务器(丢包重传),服务器收到后按照序号重排数据包 (增加TCP头部,IP头部)发送HTTP请求(请求行,请求头,请求体)HTTP 1.1中支持keep-alive属性,TCP链接不会立即关闭,后续请求可以省去建立链接时间。服务器响应结果(响应行,响应头,响应体)返回状态码为301、302时,浏览器会进行重定向操作。(重新进行导航)返回304则查找缓存。(服务端可以设置强制缓存)通过network Timing 观察请求发出的流程:Queuing: 请求发送前会根据优先级进行排队,同时每个域名最多处理6个TCP链接,超过的也会进行排队,并且分配磁盘空间时也会消耗一定时间。Stalled :请求发出前的等待时间(处理代理,链接复用)DNS lookup :查找DNS的时间initial Connection :建立TCP链接时间SSL: SSL握手时间(SSL协商)Request Sent :请求发送时间(可忽略)Waiting(TTFB) :等待响应的时间,等待返回首个字符的时间Content Dowloaded :用于下载响应的时间蓝色:DOMContentLoaded:DOM构建完成的时间 红色:Load:浏览器所有资源加载完毕本质上,浏览器是方便一般互联网用户通过界面解析和发送HTTP协议的软件3.HTTP发展历程HTTP/0.9 在传输过程中没有请求头和请求体,服务器响应没有返回头信息,内容采用ASCII字符流来进行传输 HTMLHTTP/1.0 增加了请求头和响应头,实现多类型数据传输HTTP/1.1 默认开启持久链接,在一个TCP链接上可以传输多个HTTP请求 , 采用管线化的方式(每个域名最多维护6个TCP持久链接)解决队头阻塞问题 (服务端需要按顺序依次处理请求)。完美支持数据分块传输(chunk transfer),并引入客户端cookie机制、安全机制等。HTTP/2.0 解决网络带宽使用率低 (TCP慢启动,多个TCP竞争带宽,队头阻塞)采用多路复用机制(一个域名使用一个TCP长链接,通过二进制分帧层来实现)。头部压缩(HPACK)、及服务端推送HTTP/3.0 解决TCP队头阻塞问题, 采用QUIC协议。QUIC协议是基于UDP的 (目前:支持和部署是最大的问题)HTTP明文传输,在传输过程中会经历路由器、运营商等环节,数据有可能被窃取或篡改 (安全问题)对比HTTP/1.1 和 HTTP/2 的差异4.渲染流程1.浏览器无法直接使用HTML,需要将HTML转化成DOM树。(document)2.浏览器无法解析纯文本的CSS样式,需要对CSS进行解析,解析成styleSheets。CSSOM(document.styleSeets)3.计算出DOM树中每个节点的具体样式(Attachment)4.创建渲染(布局)树,将DOM树中可见节点,添加到布局树中。并计算节点渲染到页面的坐标位置。(layout)5.通过布局树,进行分层 (根据定位属性、透明属性、transform属性、clip属性等)生产图层树6.将不同图层进行绘制,转交给合成线程处理。最终生产页面,并显示到浏览器上 (Painting,Display)查看layer并对图层进行绘制的列表
浏览器原理与实践系列文章内容来自极客时间李兵老师《浏览器工作原理与实践》,主要是记录自己的学习过程,基于自己的理解对内容做的一些总结,包括《宏观视角下的浏览器:地址栏键入URL后会发生什么?HTML,CSS,JS又是怎么变成页面显示出来的》《JS中内存机制、数据类型、V8引擎垃圾回收原理、V8怎样执行JS代码,据此可以做哪些性能优化》《V8引擎工作原理》《事件循环系统:宏任务微任务如何有条不紊的执行?》《浏览器中的页面:重绘重排及合成?如何提高页面渲染性能》《网络协议:http1、http2、http3都有些什么优缺点?》《浏览器安全:xss及CSRF攻击有何特点?如何防御》共七篇,此为第三篇JS的内存机制JS是弱类型、动态语言:弱类型:不需要告诉JavaScript引擎这个或那个变量是什么数据类型,JavaScript引擎在运行代码的时候自己会计算出来。动态:可以使用同一个变量保存不同类型的数据。JS数据类型分类:7种基本数据类型(number,string,undefined,null,boolean,bigInt,symbol)和一种引用数据类型(object,包括:Object, Function, Array ...)。基本数据类型储存在栈空间中,引用数据类型储存在堆空间中,引用数据类型的指针储存在栈空间。因为JavaScript引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。比如一个函数执行结束了,JavaScript引擎需要离开当前的执行上下文,只需要将指针下移到上个执行上下文的地址就可以了,当前执行完的函数执行上下文栈区空间全部回收。由于栈空间设计比较小,容易栈溢出,用于存放基本数据类型的小数据,堆空间很大,就可以存储很多大数据。原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址。(深浅拷贝)V8引擎垃圾回收机制数据被使用之后,可能就不再需要了,这种数据称为垃圾数据。如果这些垃圾数据一直保存在内存中,那么内存会越用越多,所以需要对这些垃圾数据进行回收,以释放有限的内存空间。如果某块数据已经不需要了,却依旧保留在内存中,这种情况称为内存泄漏。JavaScript是一门自动垃圾回收的语言,产生的垃圾数据是由垃圾回收器来释放,并不需要手动通过代码来释放。代际假说:大部分对象在内存中存在的时间很短,即很多对象一经分配,很快便变得不可访问。不死的对象,会活的更久V8引擎的垃圾回收策略就是建立在代际假说的基础上的。V8会把堆分为分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。对于这两块区域,V8分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收:副垃圾回收器,主要负责新生代的垃圾回收。主垃圾回收器,主要负责老生代的垃圾回收。不论什么类型的垃圾回收器,都有一套共同的执行流程:第一步是标记空间中活动对象和非活动对象。所谓活动对象就是还在使用的对象,非活动对象就是可以进行垃圾回收的对象。第二步是回收非活动对象所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。第三步是做内存整理。一般来说,频繁回收对象后,内存中就会存在大量不连续空间,称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大连续内存的时候,就有可能出现内存不足的情况。所以最后一步需要整理这些内存碎片,但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片,比如副垃圾回收器。副垃圾回收器原理:副垃圾回收器主要负责新生区的垃圾回收。通常情况下,大多数小的对象都会被分配到新生区,所以这个区域虽然不大,但是垃圾回收还是比较频繁的。新生代中用Scavenge算法来处理。所谓Scavenge算法,是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段,副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。由于新生代中采用的Scavenge算法,所以每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域。但复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小。也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript引擎采用了对象晋升策略,也就是经过两次垃圾回收依然还存活的对象,会被移动到老生区中。主垃圾回收器原理:主垃圾回收器主要负责老生区中的垃圾回收。除了新生区中晋升的对象,一些大的对象会直接被分配到老生区。因此老生区中的对象有两个特点,一个是对象占用空间大,另一个是对象存活时间长。由于老生区的对象比较大,若要在老生区中使用Scavenge算法进行垃圾回收,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高,同时还会浪费一半的空间。因而,主垃圾回收器是采用标记-清除(Mark-Sweep) 的算法进行垃圾回收的。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。标记完成后直接将垃圾数据清除达到垃圾回收的目的。标记过程和清除过程就是标记-清除算法,不过对一块内存多次执行标记-清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了另外一种算法——标记-整理(Mark-Compact) ,这个标记过程仍然与标记-清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。V8是如何执行JS代码的JavaScript属于解释型语言,即在每次运行时都需要通过解释器对程序进行动态解释和执行。与之对应的还有编译型语言,在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了。在编译型语言的编译过程中,编译器首先会依次对源代码进行词法分析、语法分析,生成抽象语法树(AST),然后是优化代码,最后再生成处理器能够理解的机器码。如果编译成功,将会生成一个可执行的文件。但如果编译过程发生了语法或者其他的错误,那么编译器就会抛出异常,最后的二进制文件也不会生成成功。在解释型语言的解释过程中,同样解释器也会对源代码进行词法分析、语法分析,并生成抽象语法树(AST),不过它会再基于抽象语法树生成字节码,最后再根据字节码来执行程序、输出结果。V8执行js过程:生成抽象语法树(AST)和执行上下文:高级语言是开发者可以理解的语言,对于编译器或者解释器来说,它们可以理解的是AST。所以无论解释型语言还是编译型语言,在编译过程中,都会生成一个AST。(补充:AST是非常重要的一种数据结构,在很多项目中有着广泛的应用。其中最著名的一个项目是Babel。Babel是一个被广泛使用的代码转码器,可以将ES6代码转为ES5代码,这意味着可以直接用ES6编写程序,而不用担心现有环境是否支持ES6。Babel的工作原理就是先将ES6源码转换为AST,然后再将ES6语法的AST转换为ES5语法的AST,最后利用ES5的AST生成JavaScript源代码。除了Babel外,还有ESLint也使用AST。ESLint是一个用来检查JavaScript编写规范的插件,其检测流程也是需要将源码转换为AST,然后再利用AST来检查代码规范化的问题。)生成AST需要两个过程: 第一阶段是分词,又称为词法分析,其作用是将一行行的源码拆解成一个个token。所谓token,指的是语法上不可能再分的、最小的单个字符或字符串。 第二阶段是解析,又称为语法分析,其作用是将上一步生成的token数据,根据语法规则转为AST。如果源码符合语法规则,这一步就会顺利完成。但如果源码存在语法错误,这一步就会终止,并抛出一个“语法错误”。生成字节码:有了AST和执行上下文后,解释器Ignition就可以根据AST生成字节码,并解释执行字节码。(字节码是介于AST和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。 )执行代码:生成字节码之后,就进入执行阶段了。通常,如果有一段第一次执行的字节码,解释器Ignition会逐条解释执行。解释器Ignition除了负责生成字节码之外,它还有另外一个作用,就是解释执行字节码。在Ignition执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器TurboFan就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。 字节码配合解释器和编译器,这种技术称为即时编译(JIT)JavaScript性能优化提升单次脚本的执行速度,避免JavaScript的长任务霸占主线程,这样可以使得页面快速响应交互;避免大的内联脚本,因为在解析HTML的过程中,解析和编译也会占用主线程;减少JavaScript文件的容量,因为更小的文件会提升下载速度,并且占用更低的内存。
请求报文格式起始行:[方法][空格][请求URL][HTTP版本][换行符]首部: [首部名称][:][空格][首部内容][换行符]首部结束:[换行符]实体响应报文格式起始行:[HTTP版本][空格][状态码][空格][原因短语][换行符]首部:[首部名称][:][空格][首部内容][换行符]首部结束: [换行符]实体1.基于TCP发送HTTP请求const net = require('net') class HTTPRequest { constructor(options) { this.method = options.method || 'GET'; this.host = options.host || '127.0.0.1'; this.port = options.port || 80; this.path = options.path || '/'; this.headers = options.headers || {} } send(body) { return new Promise((resolve, reject) => { body = Object.keys(body).map(key => (`${key}=${encodeURIComponent(body[key])}`)).join('&'); if (body) { this.headers['Content-Length'] = body.length; }; const socket = net.createConnection({ host:this.host, port:this.port },()=>{ const rows = []; rows.push(`${this.method} ${this.path} HTTP/1.1`); Object.keys(this.headers).forEach(key=>{ rows.push(`${key}: ${this.headers[key]}`); }); let request = rows.join('\r\n') + '\r\n\r\n' + body; socket.write(request) }); socket.on('data',function(data){ // data 为发送请求后返回的结果 }) }) } } async function request() { const request = new HTTPRequest({ method: 'POST', host: '127.0.0.1', port: 3000, path: '/', headers: { name: 'zhufeng', age: 11 } }); let { responseLine, headers, body } = await request.send({ address: '北京' }); } request(); 2.解析响应结果const parser = new HTTPParser() socket.on('data',function(data){ // data 为发送请求后返回的结果 parser.parse(data); if(parser.result){ resolve(parser.result) } }); 3.解析HTMLlet stack = [{ type: 'document', children: [] }]; const parser = new htmlparser2.Parser({ onopentag(name, attributes) { let parent = stack[stack.length - 1]; let element = { tagName: name, type: 'element', children: [], attributes, parent } parent.children.push(element); element.parent = parent; stack.push(element); }, ontext(text) { let parent = stack[stack.length - 1]; let textNode = { type: 'text', text } parent.children.push(textNode) }, onclosetag(tagname) { stack.pop(); } }); parser.end(body) 4.解析CSSconst cssRules = []; const css = require('css'); function parserCss(text) { const ast = css.parse(text); cssRules.push(...ast.stylesheet.rules); } const parser = new htmlparser2.Parser({ onclosetag(tagname) { let parent = stack[stack.length - 1]; if (tagname == 'style') { parserCss(parent.children[0].text); } stack.pop(); } }); 5.计算样式function computedCss(element) { let attrs = element.attributes; // 获取元素属性 element.computedStyle = {}; // 计算样式 Object.entries(attrs).forEach(([key, value]) => { cssRules.forEach(rule => { let selector = rule.selectors[0]; if ((selector == '#'+value && key == 'id') || (selector == '.'+value && key == 'class')) { rule.declarations.forEach(({ property, value }) => { element.computedStyle[property] = value; }) } }) }); } 6.布局绘制function layout(element) { // 计算位置 -> 绘制 if (Object.keys(element.computedStyle).length != 0) { let { background, width, height, top, left } = element.computedStyle let code = ` let canvas = document.getElementById('canvas'); canvas.width = window.innerWidth; canvas.height = window.innerHeight ; let context = canvas.getContext("2d") context.fillStyle = "${background}"; context.fillRect(${top}, ${left}, ${parseInt(width)}, ${parseInt(height)}); ` fs.writeFileSync('./code.js', code); } } 总结:DOM如何生成的当服务端返回的类型是text/html时,浏览器会将收到的数据通过HTMLParser进行解析 (边下载边解析)在解析前会执行预解析操作,会预先加载JS、CSS等文件字节流 -> 分词器 -> Tokens -> 根据token生成节点 -> 插入到 DOM树中遇到js:在解析过程中遇到script标签,HTMLParser会停止解析,(下载)执行对应的脚本。在js执行前,需要等待当前脚本之上的所有CSS加载解析完毕(js是依赖css的加载)CSS样式文件尽量放在页面头部,CSS加载不会阻塞DOM tree解析,浏览器会用解析出的DOM TREE和 CSSOM 进行渲染,不会出现闪烁问题。如果CSS放在底部,浏览是边解析边渲染,渲染出的结果不包含样式,后续会发生重绘操作。JS文件放在HTML底部,防止JS的加载、解析、执行堵塞页面后续的正常渲染
打开一个页面,为什么有4个进程?Chrome打开一个页面需要启动多少进程?点击Chrome浏览器右上角的“选项”菜单,选择“更多工具”子菜单,点击“任务管理器”,这将打开Chrome的任务管理器的窗口,如下图:可以看到浏览器至少打开了四个进程,1个网络进程、1个浏览器进程、1个GPU进程以及1个渲染进程。在了解这个问题之前,先看看另一个知识点--并行处理。什么是并行处理?计算机中的并行处理就是同一时刻处理多个任务,比如要计算下面这三个表达式的值,并显示出结果。A = 1+2 B = 20/5 C = 7*8 在编写代码的时候,可以把这个过程拆分为四个任务:任务1 是计算A=1+2;任务2 是计算B=20/5;任务3 是计算C=7*8;任务4 是显示最后计算的结果。正常情况下程序可以使用单线程来处理,也就是分四步按照顺序分别执行这四个任务。如果采用多线程,则只需分“两步走”:第一步,使用三个线程同时执行前三个任务;第二步,再执行第四个显示任务。通过对比分析,会发现用单线程执行需要四步,而使用多线程只需要两步。因此,使用并行处理能大大提升性能。线程 VS 进程多线程可以并行处理任务,但是线程是不能单独存在的,它是由进程来启动和管理的。那什么又是进程呢?一个进程就是一个程序的运行实例。详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,这样的一个运行环境叫进程。以下两张图演示了单线程和多线程处理上面所举例子中的问题:从图中可以看到,线程是依附于进程的,而进程中使用多线程并行处理能提升运算效率。总结来说,进程和线程之间的关系有以下4个特点。1. 进程中的任意一线程执行出错,都会导致整个进程的崩溃。模拟以下场景:A = 1+2 B = 20/0 C = 7*8从上图可以看出,线程1、线程2、线程3分别把执行的结果写入A、B、C中,然后线程2继续从A、B、C中读取数据,用来显示执行结果。 3. 当一个进程关闭之后,操作系统会回收进程所占用的内存。 当一个进程退出时,操作系统会回收该进程所申请的所有资源;即使其中任意线程因为操作不当导致内存泄漏,当进程退出时,这些内存也会被正确回收。 比如之前的IE浏览器,支持很多插件,而这些插件很容易导致内存泄漏,这意味着只要浏览器开着,内存占用就有可能会越来越多,但是当关闭浏览器进程时,这些内存就都会被系统回收掉。 4. 进程之间的内容相互隔离。 进程隔离是为保护操作系统中进程互不干扰的技术,每一个进程只能访问自己占有的数据,也就避免出现进程A写入数据到进程B的情况。正是因为进程之间的数据是严格隔离的,所以一个进程如果崩溃了,或者挂起了,是不会影响到其他进程的。如果进程之间需要进行数据的通信,这时候,就需要使用用于进程间通信(IPC)的机制了。 早期浏览器是单进程,即所有功能模块都运行在同一个进程里,因此会导致很多问题:不稳定,不流畅也不安全,如某一程序出错,就会导致整个进程崩溃,以致于整浏览器崩溃 目前多进程架构 这是最新的Chrome进程架构图 作者:自驱 链接:https://juejin.cn/post/7055496725963735071 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。把上述三个表达式稍作修改,在计算B的值的时候,把表达式的分母改成0,当线程执行到B = 20/0时,由于分母为0,线程会执行出错,这样就会导致整个进程的崩溃,当然另外两个线程执行的结果也没有了。2. 线程之间共享进程中的数据。如下图所示,线程之间可以对进程的公共数据进行读写操作。从上图可以看出,线程1、线程2、线程3分别把执行的结果写入A、B、C中,然后线程2继续从A、B、C中读取数据,用来显示执行结果。3. 当一个进程关闭之后,操作系统会回收进程所占用的内存。当一个进程退出时,操作系统会回收该进程所申请的所有资源;即使其中任意线程因为操作不当导致内存泄漏,当进程退出时,这些内存也会被正确回收。比如之前的IE浏览器,支持很多插件,而这些插件很容易导致内存泄漏,这意味着只要浏览器开着,内存占用就有可能会越来越多,但是当关闭浏览器进程时,这些内存就都会被系统回收掉。4. 进程之间的内容相互隔离。进程隔离是为保护操作系统中进程互不干扰的技术,每一个进程只能访问自己占有的数据,也就避免出现进程A写入数据到进程B的情况。正是因为进程之间的数据是严格隔离的,所以一个进程如果崩溃了,或者挂起了,是不会影响到其他进程的。如果进程之间需要进行数据的通信,这时候,就需要使用用于进程间通信(IPC)的机制了。早期浏览器是单进程,即所有功能模块都运行在同一个进程里,因此会导致很多问题:不稳定,不流畅也不安全,如某一程序出错,就会导致整个进程崩溃,以致于整浏览器崩溃目前多进程架构这是最新的Chrome进程架构图从图中可以看出,最新的Chrome浏览器包括:1个浏览器(Browser)主进程、1个 GPU 进程、1个网络(NetWork)进程、多个渲染进程和多个插件进程。浏览器进程。主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。渲染进程。核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎Blink和JavaScript引擎V8都是运行在该进程中,默认情况下,Chrome会为每个Tab标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。GPU进程。其实,Chrome刚开始发布的时候是没有GPU进程的。而GPU的使用初衷是为了实现3D CSS的效果,只是随后网页、Chrome的UI界面都选择采用GPU来绘制,这使得GPU成为浏览器普遍的需求。最后,Chrome在其多进程架构上也引入了GPU进程。网络进程。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。插件进程。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。不过凡事都有两面性,虽然多进程模型提升了浏览器的稳定性、流畅性和安全性,但同样不可避免地带来了一些问题:更高的资源占用。因为每个进程都会包含公共基础结构的副本(如JavaScript运行环境),这就意味着浏览器会消耗更多的内存资源。更复杂的体系架构。浏览器各模块之间耦合性高、扩展性差等问题,会导致现在的架构已经很难适应新的需求了
前言在前三篇文章中,从为什么要使用Sliver,再根据使用频率逐个解析Slivers系列的组件。相信您已经入门了Sliver的世界。为了更好的将Slivers相关的组件结合起来使用,本文将通过一个综合的案例来帮助你理解。源代码:www.aliyundrive.com/s/mPCDFwRv4…效果图话不多说,先上效果图,有图有真相!页面框架搭建顶部SliverAppBar( //指定状态栏(status bar)的亮度为暗色 systemOverlayStyle: const SystemUiOverlayStyle(statusBarBrightness: Brightness.dark), expandedHeight: 275.0, backgroundColor: Colors.white, elevation: 0.0, pinned: true, stretch: true, flexibleSpace: FlexibleSpaceBar( background: Image.asset( 'assets/images/back_image.png', fit: BoxFit.cover, ), stretchModes: const [ StretchMode.blurBackground, StretchMode.zoomBackground, ], ), leadingWidth: 80.0, //裁剪为圆角矩形 leading: ClipRRect( borderRadius: BorderRadius.circular(56.0), //模糊滤镜 child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 3.0, sigmaY: 3.0), child: Container( height: 56.0, width: 56.0, alignment: Alignment.center, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white.withOpacity(0.20), ), child: SvgPicture.asset('assets/images/icon/arrow-ios-back-outline.svg'), ), ), ), ); 底部装饰bottom: PreferredSize( preferredSize: const Size.fromHeight(0.0), child: Container( height: 32.0, alignment: Alignment.center, decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.only( topLeft: Radius.circular(32.0), topRight: Radius.circular(32.0), ), ), child: Container( width: 40.0, height: 5.0, decoration: BoxDecoration( color: kOutlineColor, borderRadius: BorderRadius.circular(100.0), ), ), ), ), 使用SliverToBoxAdapter来使用基于Box协议的组件SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(horizontal: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '意大利面拌42号混凝土', style: Theme.of(context).textTheme.titleMedium, ), ... ], ), ), ), 通过SliverPersistentHeader制作菜品展示区域class Menu extends SliverPersistentHeaderDelegate { ... @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { ... return Container( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ... Text( '菜品展示', style: Theme.of(context).textTheme.titleMedium, ), Expanded( child: Stack( children: [ //控制层叠关系 if (percent > uploadlimit) ...[ card, bottomsliverbar ] else ...[ bottomsliverbar, card ] ], ), ), ], ), ); } @override double get maxExtent => maxExtended; @override double get minExtent => minExtended; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => false; } 反转叠加动画通过Stack结合Transform实现@override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { //shrinkOffset为SliverPersistentHeader滚动偏移量,用于对应图片的偏移程度 final percent = shrinkOffset / 180; //限制图片偏移的触发范围 final uploadlimit = 13 / 120; //使用clamp限制范围 final valueback = (1 - percent - 0.77).clamp(0, uploadlimit); //将percent的值取平方,用于菜品展示图片下方背景块的位置偏移 final fixrotation = pow(percent, 1.5); //背景 final bottomsliverbar = _CustomBottomSliverBar( size: size, fixrotation: fixrotation, percent: percent); //菜品图片 final card = _CoverCard( valueback: valueback, size: size, percent: percent, uploadlimit: uploadlimit); return Container( ... ); } 图片变换的布局使用 Matrix4.identity()..rotateZ(...)实现绕 Z 轴的旋转变换。Positioned( top: size.height * 0.005, left: size.width / 24, child: Transform( alignment: Alignment.topRight, transform: Matrix4.identity() ..rotateZ(percent > uploadlimit ? (valueback * angleForCard) : percent * angleForCard), child: CoverPhoto(size: size), )) //CoverPhoto Container( ... decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), image: DecorationImage( ... fit: BoxFit.cover, )), ) 图片不规则背景+修复动画背景块通过CustomPainter进行绘制@override void paint(Canvas canvas, Size size) { final paint = Paint(); paint.color = backgroundcolor; paint.style = PaintingStyle.fill; paint.strokeWidth = 10; final path = Path(); path.moveTo(0, size.height); path.lineTo(size.width, size.height); path.lineTo(size.width, 0); path.lineTo(size.width * 0.27, 0); canvas.drawPath(path, paint); } 修复动画通过Positioned的left做出视觉上的一个视差Positioned( right: 0, bottom: 0, left: -size.width * fixrotation.clamp(0, 0.35), child: Container( height: size.height * 0.12, child: Stack( fit: StackFit.expand, children: [ CustomPaint( painter: CutRectangle(), ) ], ), )) 剩余部分剩余部分都是通过SliverToBoxAdapter来进行实现,具体布局的内容不是本文的重点,就不过多阐述了,详见源代码。总结至此,三篇组件分解文章+一篇综合实战文章,我们学习了Sliver的使用和特性,相信您已经进入了Sliver的世界。我所写的也只是它魅力的冰山一角,Sliver系列组件是用于创建灵活的滚动界面和复杂布局的关键,那么请继续探索Sliver的世界,利用其强大的特性和灵活的组合方式,创建出更加有趣和具有交互性的滚动界面吧~(后续还会有更多的使用教程、源码分解...)
本文先从 Vue 项目在整体上的执行流程谈起,然后详细介绍性能优化的两个重要方面:网络请求优化和代码效率优化。不过要明确一点,那就是在性能优化之外,用户体验才是性能优化的目的用户输入 URL 到页面显示的过程简单来说,就是用户在输入 URL 并且敲击回车之后,浏览器会去查询当前域名对应的 IP 地址。对于 IP 地址来说,它就相当于域名后面的服务器在互联网世界的门牌号。然后,浏览器会向服务器发起一个网络请求,服务器会把浏览器请求的 HTML 代码返回给浏览器。之后,浏览器会解析这段 HTML 代码,并且加载 HTML 代码中需要加载的 CSS 和 JavaScript,然后开始执行 JavaScript 代码。进入到项目的代码逻辑中,可以看到 Vue 中通过 vue-router 计算出当前路由匹配的组件,并且把这些组件显示到页面中,这样页面就完全显示出来了。而性能优化的主要目的,就是让页面显示过程的时间再缩短一些。网络请求优化对于前端来说,可以优化的点,首先就是在首页的标签中,使用标签去通知浏览器对页面中出现的其他域名去做 DNS 的预解析,比如页面中的图片通常都是放置在独立的 CDN 域名下,这样页面加载首页的时候就能预先解析域名并把结果缓存起来 。以淘宝网的首页为例进行分析,可以在淘宝的首页源码中看到下图所示的一列 dns-prefetch 标签,这样首页再出现 img.alicdn.com 这个域名请求的时候,浏览器就可以从缓存中直接获取对应的 IP 地址。项目在整体流程中,会通过 HTTP 请求加载很多的 CSS、JavaScript,以及图片等静态资源。为了让这些文件在网络加载中更快,可以从后面这几方面入手进行优化。首先,浏览器在获取网络文件时,需要通过 HTTP 请求,HTTP 协议底层的 TCP 协议每次创建链接的时候,都需要三次握手,而三次握手会造成额外的网络损耗。如果浏览器需要获取的文件较多,那就会因为三次握手次数过多,而带来过多网络损耗的问题。所以,首先需要的是让文件尽可能地少,这就诞生出一些常见的优化策略,比如先给文件打包,之后再上线;使用 CSS 雪碧图来进行图片打包等等。文件打包这条策略在 HTTP2 全面普及之前还是有效的,但是在 HTTP2 普及之后,多路复用可以优化三次握手带来的网络损耗。其次,除了让文件尽可能少,还可以想办法让这些文件尽可能地小一些,因为如果能减少文件的体积,那文件的加载速度自然也就会变快。这一环节也诞生出一些性能优化策略,比如 CSS 和 JavaScript 代码会在上线之前进行压缩;在图片格式的选择上,对于大部分图片来说,需要使用 JPG 格式,精细度要求高的图片才使用 PNG 格式;优先使用 WebP 等等。也就是说,尽可能在同等像素下,选择体积更小的图片格式。在性能优化中,懒加载的方式也被广泛使用。图片懒加载的意思是:可以动态计算图片的位置,只需要正常加载首屏出现的图片,其他暂时没出现的图片只显示一个占位符,等到页面滚动到对应图片位置的时候,再去加载完整图片。除了图片,项目中也会做路由懒加载,现在项目打包后,所有路由的代码都在首页一起加载。但也可以把不常用的路由单独打包,在用户访问到这个路由的时候再去加载代码。下面的代码中,vue-router 也提供了懒加载的使用方式,只有用户访问了 /course/:id 这个页面后,对应页面的代码才会加载执行。 { path: '/course/:id', component: () => import('../pages/courseInfo'), }, 在文件大小的问题上,Lighthouse 已经给了比较详细的优化方法,比如控制图片大小、减少冗余代码等等,可以在项目打包的时候,使用可视化的插件来查看包大小的分布。到项目根目录下,通过执行 npm install 操作来安装插件 rollup-plugin-visualizer。使用这个插件后,就可以获取到代码文件大小的报告了。之后,进入到 vite.config.js 这个文件中,新增下列代码,就可以在 Vite 中加载可视化分析插件。 import { visualizer } from 'rollup-plugin-visualizer' export default defineConfig({ plugins: [vue(),vueJsx(), visualizer()], }) 然后,在项目的根目录下执行 npm run build 命令后,项目就把项目代码打包在根目录的 dist 目录下,并且根目录下多了一个文件 stat.html。用浏览器打开这个 stat 文件,就能看到下面的示意图。项目中的 ECharts 和 Element3 的体积远远大于项目代码的体积,这时候就需要用懒加载和按需加载的方式,去优化项目整体的体积。那么这些文件如何才能高效复用呢?我们需要做的,就是尽可能高效地利用浏览器的缓存机制,在文件内容没有发生变化的时候,做到一次加载多次使用,项目中如果成功复用一个几百 KB 的文件,对于性能优化来说是一个巨大的提升。浏览器的缓存机制有好几个 Headers 可以实现,Expires、Cache-control,last-modify、etag 这些缓存相关的 Header 可以让浏览器高效地利用文件缓存。我们需要做的是,只有当文件的内容修改了,才会重新加载文件。这也是为什么项目执行 npm run build 命令之后,静态资源都会带上一串 Hash 值,因为这样确保了只有文件内容发生变化的时候,文件名才会发生变化,其他情况都会复用缓存。代码效率优化在浏览器加载网络请求结束后,页面开始执行 JavaScript,因为 Vue 已经对项目做了很多内部的优化,所以在代码层面,我们需要做的优化并不多。很多 Vue 2 中的性能优化策略,在 Vue 3 时代已经不需要了,我们需要做的就是遵循 Vue 官方的最佳实践,其余的交给 Vue 自身来优化就可以了。比如 computed 内置有缓存机制,比使用 watch 函数好一些;组件里也优先使用 template 去激活 Vue 内置的静态标记,也就是能够对代码执行效率进行优化;v-for 循环渲染一定要有 key,从而能够在虚拟 DOM 计算 Diff 的时候更高效复用标签等等。然后就是 JavaScript 本身的性能优化,或者说某些实现场景算法的选择了,这里需要具体问题具体分析,在通过性能监测工具发现代码运行的瓶颈后,依次对耗时过长的函数进行优化即可。比如下面的代码,实现了一个斐波那契数列,也就是说,在实现的这个数列中,每一个数的值是前面两个数的值之和。可以使用简单的递归算法实现斐波那契数列后,在页面显示计算结果。 function fib(n){ if(n<=1) return 1 return fib(n-1)+fib(n-2) } let count = ref(fib(38)) 上面的代码在功能上,虽然实现了斐波那契数列的要求,但是我们能够感觉到页面有些卡顿,所以可以来对页面的性能做一下检测。打开调试窗口中的 Performance 面板,使用录制功能后,便可得到下面的火焰图。通过这个火焰图,可以清晰地定位出这个项目中,整体而言耗时最长的 fib 函数,并且能看到这个函数被递归执行了无数次。到这里,不难意识到这段代码有性能问题。不过,定位到问题出现的地方之后,代码性能的优化就变得方向明确了。下面的代码中,使用递推的方式优化了斐波那契数列的计算过程,页面也变得流畅起来,这样优化就算完成了。其实对于斐波那契数列的计算而言,得到最好性能的方式是使用数学公式 + 矩阵来计算。不过在项目瓶颈到来之前,采用下面的算法已经足够了,这也是性能优化另外一个重要原则,那就是不要过度优化。 function fib(n){ let arr = [1,1] let i = 2 while(i<=n){ arr[i] = arr[i-1]+arr[i-2] i++ } return arr[n] } 用户体验优化性能优化的主要目的,还是为了能让用户在浏览网页的时候感觉更舒服,所以有些场景不能只考虑单纯的性能指标,还要结合用户的交互体验进行设计,必要的时候,可以损失一些性能去换取交互体验的提升。比如用户加载大量图片的同时,如果本身图片清晰度较高,那直接加载的话,页面会有很多图一直是白框。所以可以预先解析出图片的一个模糊版本,加载图片的时候,先加载这个模糊的图作为占位符,然后再去加载清晰的版本。虽然额外加载了图片文件,但是用户在体验上得到了提升。类似的场景还有很多,比如用户上传文件的时候,如果文件过大,那么上传可能就会很耗时。而且一旦上传的过程中发生了网络中断,那上传就前功尽弃了。为了提高用户的体验,可以选择断点续传,也就是把文件切分成小块后,挨个上传。这样即使中间上传中断,但下次再上传时,只上传缺失的那些部分就可以了。可以看到,断点上传虽然在性能上,会造成网络请求变多的问题,但也极大地提高了用户上传的体验。还有很多组件库也会提供骨架图的组件,能够在页面还没有解析完成之前,先渲染一个页面的骨架和 loading 的状态,这样用户在页面加载的等待期就不至于一直白屏性能监测报告解释一下 FCP、TTI 和 LCP 这几个关键指标的含义。首先是 First Contentful Paint,通常简写为 FCP,它表示的是页面上呈现第一个 DOM 元素的时间。在此之前,页面都是白屏的状态;然后是 Time to interactive,通常简写为 TTI,也就是页面可以开始交互的时间;还有和用户体验相关的 Largest Contentful Paint,通常简写为 LCP,这是页面视口上最大的图片或者文本块渲染的时间,在这个时间,用户能看到渲染基本完成后的首页,这也是用户体验里非常重要的一个指标。可以通过代码中的 performance 对象去动态获取性能指标数据,并且统一发送给后端,实现网页性能的监控。性能监控也是大型项目必备的监控系统之一,可以获取到用户电脑上项目运行的状态。下图展示了 performance 中所有的性能指标,可以通过这些指标计算出需要统计的性能结果。const timing = window.performance && window.performance.timing const navigation = window.performance && window.performance.navigation // DNS 解析: const dns = timing.domainLookupEnd - timing.domainLookupStart // 总体网络交互耗时: const network = timing.responseEnd - timing.navigationStart // 渲染处理: const processing = (timing.domComplete || timing.domLoading) - timing.domLoading // 可交互: const active = timing.domInteractive - timing.navigationStart 在上面的代码中,通过 Performance API 获取了 DNS 解析、网络、渲染和可交互的时间消耗。有了这些指标后,就可以随时对用户端的性能进行检测,做到提前发现问题,提高项目的稳定性。
优化策略关键资源个数越多,首次页面加载时间就会越长关键资源的大小,内容越小,下载时间越短优化白屏:内联css和内联js移除文件下载,较小文件体积预渲染,打包时进行预渲染使用SSR加速首屏加载(耗费服务端资源),有利于SEO优化。 首屏利用服务端渲染,后续交互采用客户端渲染什么是Perfomance API衡量和分析各种性能指标对于确保 web 应用的速度非常重要。Performance API 提供了重要的内置指标,并能够将你自己的测量结果添加到浏览器的性能时间线(performance timeline)中。性能时间线使用高精度的时间戳,且可以在开发者工具中显示。你还可以将相关数据发送到用于分析的端点,以根据时间记录性能指标。关键时间节点描述含义TTFBtime to first byte(首字节时间)从请求到数据返回第一个字节所消耗时间TTITime to Interactive(可交互时间)DOM树构建完毕,代表可以绑定事件DCLDOMContentLoaded (事件耗时)当 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发LonLoad (事件耗时)当依赖的资源全部加载完毕之后才会触发FPFirst Paint(首次绘制)第一个像素点绘制到屏幕的时间FCPFirst Contentful Paint(首次内容绘制)首次绘制任何文本,图像,非空白节点的时间FMPFirst Meaningful paint(首次有意义绘制)首次有意义绘制是页面可用性的量度标准LCPLargest Contentful Paint(最大内容渲染)在viewport中最大的页面元素加载的时间FIDFirst Input Delay(首次输入延迟)用户首次和页面交互(单击链接,点击按钮等)到页面响应交互的时间<div style="background:red;height:100px;width:100px"></div> <h1 elementtiming="meaningful"></h1> <script> window.onload = function () { let ele = document.createElement('h1'); ele.innerHTML = 'zf'; document.body.appendChild(ele) } setTimeout(() => { const { fetchStart, requestStart, responseStart, domInteractive, domContentLoadedEventEnd, loadEventStart } = performance.timing; let TTFB = responseStart - requestStart; // ttfb let TTI = domInteractive - fetchStart; // tti let DCL = domContentLoadedEventEnd - fetchStart // dcl let L = loadEventStart - fetchStart; console.log(TTFB, TTI, DCL, L) const paint = performance.getEntriesByType('paint'); const FP = paint[0].startTime; const FCP = paint[1].startTime; // 2s~4s }, 2000); let FMP; new PerformanceObserver((entryList, observer) => { let entries = entryList.getEntries(); FMP = entries[0]; observer.disconnect(); console.log(FMP) }).observe({ entryTypes: ['element'] }); let LCP; new PerformanceObserver((entryList, observer) => { let entries = entryList.getEntries(); LCP = entries[entries.length - 1]; observer.disconnect(); console.log(LCP); // 2.5s-4s }).observe({ entryTypes: ['largest-contentful-paint'] }); let FID; new PerformanceObserver((entryList, observer) => { let firstInput = entryList.getEntries()[0]; if (firstInput) { FID = firstInput.processingStart - firstInput.startTime; observer.disconnect(); console.log(FID) } }).observe({ type: 'first-input', buffered: true }); </script> 网络优化策略减少HTTP请求数,合并JS、CSS,合理内嵌CSS、JS合理设置服务端缓存,提高服务器处理速度。 (强制缓存、对比缓存) sql复制代码// Expires/Cache-Control Etag/if-none-match/last-modified/if-modified-since避免重定向,重定向会降低响应速度 (301,302)使用dns-prefetch,进行DNS预解析采用域名分片技术,将资源放到不同的域名下。接触同一个域名最多处理6个TCP链接问题。采用CDN加速加快访问速度。(指派最近、高度可用)gzip压缩优化 对传输资源进行体积压缩 (html,js,css)加载数据优先级 : preload(预先请求当前页面需要的资源) prefetch(将来页面中使用的资源) 将数据缓存到HTTP缓存中 ini复制代码 <link rel="preload" href="style.css" as="style">关键渲染路径重排(回流)Reflow: 添加元素、删除元素、修改大小、移动元素位置、获取位置相关信息重绘 Repaint:页面中元素样式的改变并不影响它在文档流中的位置。我们应当尽可能减少重绘和回流1.强制同步布局问题JavaScript强制将计算样式和布局操作提前到当前的任务中<div id="app"></div> <script> function reflow() { let el = document.getElementById('app'); let node = document.createElement('h1'); node.innerHTML = 'hello'; el.appendChild(node); // 强制同步布局 console.log(app.offsetHeight); } requestAnimationFrame(reflow) </script> 2.布局抖动(layout thrashing)问题在一段js代码中,反复执行布局操作,就是布局抖动function reflow(){ let el = document.getElementById('app'); let node = document.createElement('h1'); node.innerHTML = 'hello'; el.appendChild(node); // 强制同步布局 console.log(app.offsetHeight); } window.addEventListener('load',function(){ for(let i = 0 ; i<100;i++){ reflow(); } }); 3.减少回流和重绘脱离文档流渲染时给图片增加固定宽高尽量使用css3 动画可以使用will-change提取到单独的图层中静态文件优化1.图片优化图片格式:jpg:适合色彩丰富的照片、banner图;不适合图形文字、图标(纹理边缘有锯齿),不支持透明度png:适合纯色、透明、图标,支持半透明;不适合色彩丰富图片,因为无损存储会导致存储体积大gif:适合动画,可以动的图标;不支持半透明,不适和存储彩色图片webp:适合半透明图片,可以保证图片质量和较小的体积svg格式图片:相比于jpg和jpg它的体积更小,渲染成本过高,适合小且色彩单一的图标;图片优化:避免空src的图片减小图片尺寸,节约用户流量img标签设置alt属性, 提升图片加载失败时的用户体验原生的loading:lazy 图片懒加载 arduino复制代码<img loading="lazy" src="./images/1.jpg" width="300" height="450" />不同环境下,加载不同尺寸和像素的图片 ini复制代码<img src="./images/1.jpg" sizes="(max-width:500px) 100px,(max-width:600px) 200px" srcset="./images/1.jpg 100w, ./images/3.jpg 200w">对于较大的图片可以考虑采用渐进式图片采用base64URL减少图片请求采用雪碧图合并图标图片等2.HTML优化语义化HTML:代码简洁清晰,利于搜索引擎,便于团队开发提前声明字符编码,让浏览器快速确定如何渲染网页内容减少HTML嵌套关系、减少DOM节点数量删除多余空格、空行、注释、及无用的属性等HTML减少iframes使用 (iframe会阻塞onload事件可以动态加载iframe)避免使用table布局3.CSS优化减少伪类选择器、减少样式层数、减少使用通配符避免使用CSS表达式,CSS表达式会频繁求值, 当滚动页面,或者移动鼠标时都会重新计算 (IE6,7) css复制代码background-color: expression( (new Date()).getHours()%2 ? "red" : "yellow" );删除空行、注释、减少无意义的单位、css进行压缩使用外链css,可以对CSS进行缓存添加媒体字段,只加载有效的css文件 ini复制代码<link href="index.css" rel="stylesheet" media="screen and (min-width:1024px)" />CSS contain属性,将元素进行隔离减少@import使用,由于@import采用的是串行加载4.JS优化通过async、defer异步加载文件减少DOM操作,缓存访问过的元素操作不直接应用到DOM上,而应用到虚拟DOM上。最后一次性的应用到DOM上。使用webworker解决程序阻塞问题IntersectionObserver ini复制代码const observer = new IntersectionObserver(function(changes) { changes.forEach(function(element, index) { if (element.intersectionRatio > 0) { observer.unobserve(element.target); element.target.src = element.target.dataset.src; } }); }); function initObserver() { const listItems = document.querySelectorAll('img'); listItems.forEach(function(item) { observer.observe(item); }); } initObserver();虚拟滚动 vertual-scroll-listrequestAnimationFrame、requestIdleCallback尽量避免使用eval, 消耗时间久使用事件委托,减少事件绑定个数。尽量使用canvas动画、CSS动画5.字体优化 @font-face { font-family: "Bmy"; src: url("./HelloQuincy.ttf"); font-display: block; /* block 3s 内不显示, 如果没加载完毕用默认的 */ /* swap 显示老字体 在替换 */ /* fallback 缩短不显示时间, 如果没加载完毕用默认的 ,和block类似*/ /* optional 替换可能用字体 可能不替换*/ } body { font-family: "Bmy" } `FOUT(Flash Of Unstyled Text)` 等待一段时间,如果没加载完成,先显示默认。加载后再进行切换。 `FOIT(Flash Of Invisible Text)`字体加载完毕后显示,加载超时降级系统字体 (白屏)
前言在日常的开发工作中,仅仅使用ListView、ListView.builder等这样的滑动组件就能满足大部分的业务需求,在碰到较为复杂的滑动页面时,加上Slivers系列中的几个常用组件也简单的实现。这也就导致了一些朋友没有较为完整的去了解Slivers系列。那么在重识Flutter这个专栏中,预计有5篇slivers相关的文章,从组件的使用到其背后的渲染原理,让我们一起探索Slivers的魅力吧!视窗是什么? Sliver是什么?相信点进这篇文章的朋友,一定会知道ListView这样的滚动组件,滚动组件会提供一个区块,用于滚动的显示内容,但内容很多时,我们只能看到可滚动内容中的部分内容。这个就是视窗(ViewPort),也就是列表的可视区域大小。例如一个ListView的显示区域高度为500像素,它列表项总高度可能远远超过500个像素,但是它的ViewPort仍为500像素。那么Sliver是什么呢?我们可以通过ListView.builder(),设置itemCount为null,构建一个无限的列表内容,只有当我们滚动时,才会动态的去创建需要呈现在视窗中的内容。这个就是Sliver,如果一个滚动组件支持Sliver模型,那么这个组件会将子组件分成很多个Sliver,只有当Sliver出现在视窗中才会构建。CustomScrollView像ListView、GridView等组件,在底层实现中都有着对应的Sliver,如SliverList、SliverGrid。Sliver版本的可滚动组件和非Sliver版本的可滚动组件最大的区别就是:Sliver版本的组件不包含滚动模型(Scrollable),组件自身不能滚动。所以,如果想要使用Sliver系列的组件,就需要给它们添加滚动模型。Flutter提供了CustomScrollView,做为Sliver系列组件运行的容器。CustomScrollView主要的作用就是提供Viewport和一个公共的Scrollable,多个Sliver组件共用CustomScrollView的Scrollable,就达到了单一滚动的场景。CustomScrollView有着许多属性,其中最常用的便是slivers,用来传入Sliver组件列表。就像这样:Scaffold( body: CustomScrollView( slivers: [ SliverList(/**/), SliverGrid(/**/), ], ), ); 有了CustomScrollView组件的帮助,实现复杂的滚动效果,好像不是那么困难。SliverList如果需要在一个界面创建多个列表,刚了解Flutter的朋友可能会使用Column中包裹多个ListView去实现,就像这样:但是这样的效果肯定不符合需求,如果想要让它们一起滚动,或添加一些复杂的动画,实现像这样的效果:那么借助SliverList就能很简单的实现。SliverList是Sliver Widget的一种,作用是将Widget排列在一个List中,使用SliverList需要定义delegate。Sliver delegate是用于描述使用哪种方法对组件进行渲染,通常有两种:static和builder。在SliverList中,可以定义两种类型的delegate:SliverChildListDelegate:获取需要显示的组件列表。此列表中定义的组件将被立即呈现。不会有任何延迟加载。SliverChildBuilderDelegate:获取将延迟创建的小部件列表。这意味着随着用户滚动,剩余的组件才会开始渲染。可以简单的把ListView理解为:CustomScrollView + SliverList + SliverChildListDelegate;把ListView.Builder理解为:CustomScrollView + SliverList + SliverChildBuilderDelegate。在了解了SliverList需要定义的delegate后,那么使用它就和使用ListView一样简单:CustomScrollView( slivers: [ SliverList( delegate: SliverChildListDelegate([ Container( height: 50, color: Colors.primaries[0], ), ]), ), SliverList( delegate: SliverChildBuilderDelegate((BuildContext ctx, int index) { return Container( height: 50, color: Colors.primaries[index % Colors.primaries.length], ); }, childCount: 5), ), ], ), SliverGridSliverGrid与GridView一样,将组件以一行两个或一行多个的形式排列。它除了和SliverList一样需要定义一个正常的delegate之外,还需要传入gridDelegate,用于描述每行如何显示组件。就像这样:每行最多4个,每个组件的宽度是高度的1.5倍。SliverGrid( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4, crossAxisSpacing: 5, mainAxisSpacing: 3, childAspectRatio: 1.5), delegate: SliverChildBuilderDelegate((BuildContext context, int index) { return Container( color: Colors.primaries[index % Colors.primaries.length], ); }, childCount: 20), ) 在SliverGrid中可以定义两种gridDelegate:SliverGridDelegateWithFixedCrossAxisCount:指定一行中有多少列,使用它会自动扩展到屏幕最大宽度。crossAxisCount属性是列数 childAspectRatio属性是宽高比 XXXSpacing属性是指每个item之间的边距SliverGridDelegateWithMaxCrossAxisExtent: 可以指定列的宽度maxCrossAxisExtent 是列中的最大宽度可以把GridView.builder理解为:CustomScrollView + SliverGrid + SliverChildBuilderDelegate + gridDelegate遇到一些简单的需求,也可以使用缩写组件:SliverGrid.count :SliverGrid + SliverChildListDelegate + SliverGridDelegateWithFixedCrossAxisCountSliverGrid.extent: SliverGrid + SliverChildListDelegate + SliverGridDelegateWithMaxCrossAxisExtentSliverGrid.count( crossAxisCount: 3, children: [ ...List.generate( 3, (index) => Container( color: Colors.primaries[index % Colors.primaries.length], ), ) ], ), SliverGrid.extent( maxCrossAxisExtent: 100, children: [ ...List.generate( 9, (index) => Container( color: Colors.primaries[index % Colors.primaries.length], ), ) ], ) SliverGrid与SliverList一起使用即可获得这样的效果:SliverAppBarAppBar是大部分应用程序很重要的组件,它位于App的顶部,主要控制一些可操作按钮。在Flutter中,常在Scaffold下使用AppBar组件。那么什么是SliverAppBar呢?SliverAppBar Widget是 Flutter 中用于兼容 CustomScrollView的,它与AppBar组件相同,意味着它具有AppBar的所有属性,如:title、actions、leading,但它也有一些额外的参数,如pinned, floating, snap,expandedHeight用于自定义AppBar的行为。SliverAppBar通常作为CustomScrollView slivers中的第一个组件。Scaffold( body: CustomScrollView( slivers: [ SliverAppBar( title: Text("Hello SliverAppBar & Taxze"), actions: <Widget>[ IconButton(onPressed: () => null, icon: const Icon(Icons.add)) ], ), ], ), ) 这就是一个很经典的Material应用程序的AppBar。SliverAppBar属性众多,与AppBar相同的属性在本文就不过多介绍,主要讲解其特有的属性。expandedHeight该属性定义了AppBar完全展开时的大小,高度会随着向下滚动而缩小。SliverAppBar( title: Text("Hello SliverAppBar & Taxze"), expandedHeight: 200, actions: <Widget>[ IconButton(onPressed: () => null, icon: const Icon(Icons.add)) ], ), 未设置expandedHeightexpandedHeight: 200pinned该属性用于确定当用户向下滚动时,AppBar在屏幕上是否保持可见。SliverAppBar( title: Text("Hello SliverAppBar & Taxze"), expandedHeight: 200, pinned: true, actions: <Widget>[ IconButton(onPressed: () => null, icon: const Icon(Icons.add)) ], ), pinned: truepinned: falsefloating该属性如果设置为true,则AppBar将在用户向上滚动时立即可见。如果为false那么只有当滚动到顶部才能可见。SliverAppBar( title: Text("Hello SliverAppBar & Taxze"), expandedHeight: 200, floating: true, actions: <Widget>[ IconButton(onPressed: () => null, icon: const Icon(Icons.add)) ], ), floating: truefloating: falsesnap该属性如果设置为true,那么用户向上滚动一点,即可见完整的AppBar。使用该属性需要将floating设置为true。SliverAppBar( title: Text("Hello SliverAppBar & Taxze"), expandedHeight: 200, floating: true, snap: true, actions: <Widget>[ IconButton(onPressed: () => null, icon: const Icon(Icons.add)) ], ), snap: truefloating: true在效果图中能明显看出snap和floating的明显区别。flexibleSpace该属性用于给AppBar提供background和collapseMode,还有可以随用户滚动而改变位置的title。SliverAppBar( expandedHeight: 200, flexibleSpace: FlexibleSpaceBar( title: Text("First FlexibleSpace",style: TextStyle(color: Colors.red),), background: Image.network( "https://p3-passport.byteimg.com/img/user-avatar/af5f7ee5f0c449f25fc0b32c050bf100~180x180.awebp", fit: BoxFit.cover), ), actions: <Widget>[ IconButton(onPressed: () => null, icon: const Icon(Icons.add)) ], ), 当用户向上滚动时,就会得到视差效果,这是因为collapseMode,它有三种模式:parallax , pin , none。collapseMode默认为CollapseMode.parallax,如果将其设置为pin,那么你将不会得到视差效果,只有简单的淡入淡出。flexibleSpace: FlexibleSpaceBar( title: Text("First FlexibleSpace",style: TextStyle(color: Colors.black),), collapseMode: CollapseMode.pin, background: Image.network( "https://p3-passport.byteimg.com/img/user-avatar/af5f7ee5f0c449f25fc0b32c050bf100~180x180.awebp", fit: BoxFit.cover), ), CollapseMode.parallaxCollapseMode.pinstretch使用该属性前,需要先设置CustomScrollView的physics,给它一个弹性效果,在滚动到内容尽头时仍然运行滚动。stretch属性设置为true时,会让 FlexibleSpaceBar 与外部组件同步滚动。SliverAppBar( expandedHeight: 200, pinned: true, stretch: true, flexibleSpace: FlexibleSpaceBar( title: Text("First FlexibleSpace",style: TextStyle(color: Colors.black),), // collapseMode: CollapseMode.pin, background: Image.network( "https://p3-passport.byteimg.com/img/user-avatar/af5f7ee5f0c449f25fc0b32c050bf100~180x180.awebp", fit: BoxFit.cover), ), actions: <Widget>[ IconButton(onPressed: () => null, icon: const Icon(Icons.add)) ], ), stretch: truestretch: falsestretchModes当stretch属性设置为true时,此时会触发FlexibleSpaceBar容器放大导致的背景图片变化的一个动画效果->stretchModes。stretchModes有三种属性:zoomBackground默认效果,放大背景图片blurBackground模糊背景图片fadeTitle淡化titleflexibleSpace: FlexibleSpaceBar( title: Text("First FlexibleSpace",style: TextStyle(color: Colors.black),), // collapseMode: CollapseMode.pin, background: Image.network( "https://p3-passport.byteimg.com/img/user-avatar/af5f7ee5f0c449f25fc0b32c050bf100~180x180.awebp", fit: BoxFit.cover), stretchModes: [ // StretchMode.fadeTitle, // StretchMode.blurBackground, StretchMode.zoomBackground ], ), zoomBackgroundblurBackgroundfadeTitle
前言在上一篇文章中,我们了解了Flutter中的视窗和Sliver,并通过案例知道了CustomScrollView、SliverList、SliverGrid、SliverAppBar等常用的Sliver系列组件。那么现在让我们来探索更多常用的Slivers吧!SliverToBoxAdapter如果想在CustomScrollView中添加SizedBox 、Row这样基于Box协议的组件,你会发现不能直接添加,因为在CustomScrollView中需要使用Sliver协议来实现一些东西,这时也许你会Google:我应该如何将Box协议的组件更改为Sliver协议的组件? 答案是:使用SliverToBoxAdapter即可。— 在Flutter中,主要有两种布局协议:box协议,和sliver协议。SliverToBoxAdapter只是一个用于包裹Box协议的Sliver组件。如果你想在CustomScrollView中显示一个子项时,通过它会变得非常方便。Scaffold( body: CustomScrollView( slivers: [ SliverToBoxAdapter( child: _helloText, ) ], ), ); Widget get _helloText { return Column( children: [ ...List.generate( 10, (index) => Text("Taxze SliverToBoxAdapter $index")), ElevatedButton( onPressed: () => print("Say Hello!"), child: Text("Hello")) ], ); } 但是很多朋友第一次使用该组件时,会出现使用SliverToBoxAdapter去包裹可滚动组件的情况,例如:SliverToBoxAdapter( child: ListView( children: [], ), ) 出现与sliver滑动方向一致的情况时,这个ListView便没有办法正常工作。SliverPersistentHeader在上一篇文章中已经知道了SliverAppBar有很多控制属性,但如果想要控制更多的SliverAppBar的行为或自定义AppBar,又或者想要在列表某处固定一个Item,那么可以使用SliverPersistentHeader。SliverPersistentHeader有一个必传参数和两个可选参数:pinned: 默认为false,用于控制item是否固定。例如将自定义的AppBar贴在顶部。floating: 默认为false,用于控制item是否浮动。例如向下滑动时,自定义的AppBar会展开。delegate: 必须传入的参数。该参数需要一个实现扩展抽象类的委托类SliverPersistentHeaderDelegate。现在就来实现一个TaxzePersistentHeaderDelegate,来体验SliverPersistentHeader的强大吧!可以看到有四个需要实现的方法:build 需要构建渲染的内容,其中shrinkOffset参数的取值为[0,maxExtent],当header在顶部时,值为0maxExtent 展开时组件的高度minExtent 收起时组件的高度shouldRebuild 判断Header是否需要重新构建,通常在父级状态更新时触发class TaxzePersistentHeaderDelegate extends SliverPersistentHeaderDelegate { TaxzePersistentHeaderDelegate({ required this.minSize, required this.maxSize, }); final double minSize; final double maxSize; @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { print(shrinkOffset); return Stack( fit: StackFit.expand, children: [ ... ], ); } @override bool shouldRebuild(TaxzePersistentHeaderDelegate oldDelegate) => oldDelegate.maxExtent != maxExtent || oldDelegate.minExtent != minExtent; @override double get maxExtent => maxSize; @override double get minExtent => minSize; } pinned属性和floating属性就不过多介绍了,现在通过一个实例,来看看SliverPersistentHeader能实现什么样的常用功能。效果图:实现起来也很简单,第一步可以先封装一个通用的HeaderDelegate,方便快速构建 SliverPersistentHeaderDelegate,减少重复代码。typedef SliverHeaderBuilder = Widget Function( BuildContext context, double shrinkOffset, bool overlapsContent); class TaxzePersistentHeaderDelegate extends SliverPersistentHeaderDelegate { TaxzePersistentHeaderDelegate({ this.minSize = 0, required this.maxSize, required Widget child, }) : builder = ((context, shrinkOffset, overlapsContent) => child), assert(minSize <= maxSize && minSize >= 0); final double minSize; final double maxSize; final SliverHeaderBuilder builder; @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { Widget child = builder(context, shrinkOffset, overlapsContent); //高度充满父约束,高度在[minHeight,maxHeight]之间变化 return SizedBox.expand( child: child, ); } @override bool shouldRebuild(TaxzePersistentHeaderDelegate oldDelegate) => oldDelegate.maxExtent != maxExtent || oldDelegate.minExtent != minExtent; @override double get maxExtent => maxSize; @override double get minExtent => minSize; } 使用起来也很简单:SliverPersistentHeader( pinned: true, delegate: TaxzePersistentHeaderDelegate( maxSize: 100, minSize: 60, child: buildItemHeader(1), ), ), Widget buildItemHeader(int i) { return Container( ... ); } 额外的知识点:SliverPersistentHeader 组件的设计初衷主要是为了实现 SliverAppBar,所以 SliverAppBar原理其实就像是这样:CustomScrollView( slivers: [ SliverToBoxAdapter(), //防止SliverPersistentHeader成为最顶层的Sliver,以至于无法上拉刷新 SliverPersistentHeader( delegate:XXXDelegate(), //固定在顶部 pinned: true, ) ], ), SliverFixedExtentListSliverFixedExtentList与SliverList用法一样,唯一的区别是SliverFixedExtentList是固定子组件的高度的,所以如果你确定了子组件的高度,那么请选择SliverFixedExtentList,因为它无需计算子组件的布局尺寸,更加高效!而且如果想要跳转到很远的距离时,在加载组件之前就知道尺寸是非常重要的! 例如:每个item固定为100px,现在要往下滚动2000px,那么只需要跳转20个item,只需要加载第21个item即可。如果子组件的尺寸是不固定的,那么如果想要跳转到2000px的地方,就不得不逐个渲染中间的组件,才能知道2000px的位置。CustomScrollView( slivers: [ SliverFixedExtentList( //固定尺寸 itemExtent: 100, delegate: SliverChildBuilderDelegate((ctx, index) { return Container( alignment: Alignment.center, color: Colors.primaries[index % Colors.primaries.length], child: Text( "$index", style: const TextStyle(color: Colors.white, fontSize: 18), ), ); }), ) ], ), SliverPrototypeExtentList知道了子组件的尺寸就能使用SliverFixedExtentList从而提高性能,但是在真实的业务中,子组件固定尺寸的场景较少,一般都是出现在设置页,想要在其他的场景下固定子组件的尺寸是非常麻烦的,但是,有了SliverPrototypeExtentList就简单多了!为什么说使用它会变得简单呢?在SliverPrototypeExtentList中,有一个prototypeItem属性,可以传入一个组件,但是这个组件不会被渲染到屏幕上,该组件的作用是提供一个尺寸,在SliverPrototypeExtentList中的组件尺寸都会被设置成该组件的尺寸。SliverPrototypeExtentList( prototypeItem: const Text(""), delegate: SliverChildBuilderDelegate((ctx, index) { return Text("$index"); }), ) SliverFillViewport学习这个组件之前,让我们回想一下PageView组件,PageView每一个子组件都会占据整个父约束,然后可以滑动切换。而在Sliver中,就有SliverFillViewport,它也是PageView的底层原理。在PageView的build函数中就可以看到SliverFillViewport。SliverFillViewport( delegate: SliverChildListDelegate([ Container( color: Colors.red, ), Container( color: Colors.blue, ), Container( color: Colors.green, ), ]), ), SliverFillRemainingSliverFillRemaining与SliverFillViewport很类似,SliverFillViewport是生成的每一个item都占满全屏,而SliverFillRemaining是会自动填充满整个视图。SliverFillRemaining有两个属性:hasScrollBody 默认为true,该输入用于判断内容是否可以滚动。fillOverscroll 允许在 iOS 上看到列表过度滚动时的拉伸行为。CustomScrollView( slivers: [ SliverList(delegate: SliverChildBuilderDelegate((ctx, index) { return Container( height: 50, margin: EdgeInsets.all(10), color: Colors.red, ); },childCount: 5)), SliverFillRemaining( hasScrollBody: true, child: FlutterLogo(), ) ], ), hasScrollBody:truehasScrollBody:falseSliverPadding如果想在CustomScrollView中添加Padding时,在没有了解过SliverPadding之前,也许你会想使用SliverToBoxAdapter去包裹一层Padding,但是其实不用那么麻烦,Slivers中就有SliverPadding这样的组件帮助实现需求。使用它也很简单,只需要在需要Padding的Sliver组件外报上一层即可。SliverPadding( padding: EdgeInsets.all(20.0), sliver: SliverList( delegate: SliverChildBuilderDelegate((ctx, index) { return Container( color: Colors.primaries[index % Colors.primaries.length], height: 50, ); },childCount: 10), ), )