WPF MVVM核心原理与Prism框架实战:从数据绑定到模块化架构
发布时间:2026/10/4 4:51:36 锦皓数字建站

1. MVVM到底解决了什么从一段失控的代码说起做WPF开发的人应该都有过这样的经历刚上手时觉得XAML写界面是真快拖拖控件、改改属性一个像模像样的窗口就出来了。但项目一旦超过三五个窗口、业务逻辑开始复杂你会发现后台代码文件越写越长事件处理器一个接一个界面上几十个控件的状态互相牵连改一个输入框的校验逻辑可能要动七八处地方。这时候你才意识到WPF真正的难点根本不在XAML而在怎么把界面和逻辑拆干净。MVVM框架就是为这个问题准备的。MVVM全称Model-View-ViewModel它解决的从来不是怎么写界面的问题而是界面和逻辑如何分工的问题。很多初学者把MVVM理解成把代码从code-behind挪到ViewModel里这个理解不能说错但太片面了。MVVM的核心价值在于它利用WPF强大的数据绑定和命令机制把View界面和ViewModel界面状态与交互逻辑彻底解耦让界面设计、逻辑开发、单元测试各走各的路互不耽搁。1.1 没有分层时我的WPF代码是怎么烂掉的先还原一个非常典型的场景一个简单的登录窗口。它有用户名输入框、密码输入框、登录按钮、错误提示文本还有一个记住我的复选框。如果用传统事件驱动的方式写code-behind里大概是这样的流程TextBox的TextChanged事件里判断用户名是否为空然后决定提示文字显不显示。按钮的Click事件里取两个输入框的值做非空校验调后台接口根据结果改提示文字或者跳转窗口。复选框的Checked/Unchecked事件里记住设置状态。窗口的Loaded事件里做初始化可能有从配置文件读取默认用户的逻辑。等你把这一套写完再回头看那个.xaml.cs文件七八个事件方法挤在一起每个方法里既有控件操作又有业务判断。更麻烦的是如果之后要换界面布局——比如把登录窗口改成嵌入到主窗口的一个Panel里或者要把同一个登录逻辑复用到另一个项目——这些散落在事件里的代码几乎没法迁移只能重写。这还只是登录窗口。在一个正经业务系统里一个订单编辑界面可能有二十多个控件十几个互相联动的状态再加上数据校验、格式转换、权限控制如果全用事件驱动的方式来写代码量会膨胀到完全不可维护的程度。我在实际项目里见过一个统计报表窗口的code-behind两千多行光鼠标悬停提示、列头排序、行样式切换这种和业务无关的界面逻辑就占了一半。1.2 MVVM的三层分工View管长相ViewModel管状态Model管数据MVVM把这个问题拆成了三个角色View只负责界面的展示和用户交互的收集。它包含XAML布局、样式、动画、控件行为以及把用户操作翻译成命令或绑定事件的代码。View不关心业务逻辑不直接调用后台接口不持有业务数据。ViewModel界面的状态容器和行为定义者。它暴露给View一组属性比如用户名、密码、错误提示、是否忙碌中和一组命令比如登录命令。View通过Binding把控件和这些属性、命令连起来。ViewModel不知道自己被什么样的View使用它只关心自身属性的变化和命令的执行。Model业务数据和业务规则。包括数据实体、数据库访问、文件读写、调用第三方服务等。Model同样不知道ViewModel和View的存在。这三层之间靠两种机制通信数据绑定负责把ViewModel的属性值同步到界面控件上命令绑定负责把按钮点击等操作转发给ViewModel的方法。ViewModel通过INotifyPropertyChanged接口通知界面某个属性变了请刷新显示。这就是MVVM的全部核心机制。理解了这个分工你再看WPF会有一个全新的视角WPF的绑定引擎、依赖属性、DataContext、CommandBinding这套东西几乎就是为MVVM量身定做的。与其说MVVM是一种设计模式不如说它是WPF这套基础设施的正确打开方式。不搞懂MVVM你对WPF的理解基本停留在JS操作DOM的层次也体会不到WPF相比WinForm的核心优势在哪里。2. 手写最小MVVM基础设施不依赖框架也能跑很多教程一上来就让你装Prism、装MVVMLight然后照着模板建项目。我的建议恰恰相反先别急着上框架自己手写一套最小的MVVM基础设施把原理彻底搞明白再用框架你会觉得一切都顺理成章。因为所谓MVVM框架本质上就是帮你把最常用的几个基础设施——通知机制、命令封装、绑定辅助——写好并提供一些额外能力原理并不复杂。2.1 通知机制ViewModel怎么告诉View我变了INotifyPropertyChanged是整个MVVM的地基。只有实现这个接口ViewModel的属性值发生变化时绑定引擎才能收到通知并刷新界面。接口本身很简单只有一个事件public class ViewModelBase : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } protected bool SetPropertyT(ref T storage, T value, [CallerMemberName] string? propertyName null) { if (EqualityComparerT.Default.Equals(storage, value)) return false; storage value; OnPropertyChanged(propertyName); return true; } }这里用了CallerMemberName特性好处是调用SetProperty和OnPropertyChanged时不用手写属性名字符串编译器自动填充省得属性重命名时字符串忘改导致绑定失效。这是个很实用的细节建议从一开始就养成用CallerMemberName的习惯。有了这个基类ViewModel里的属性写法就固定成了这样private string _userName string.Empty; public string UserName { get _userName; set SetProperty(ref _userName, value); }你可能会觉得这写法啰嗦每写一个属性都要配一个私有字段。这是MVVM的常态习惯了就好。也可以考虑用源生成器Source Generator或第三方库来减少样板代码但基础系列里我建议还是把标准写法练扎实。2.2 RelayCommand把按钮点击变成可测试的命令WPF的Button有Command属性但WPF内置的RoutedCommand用起来并不顺手尤其是参数传递和CanExecute控制方面所以实践中几乎都是自己封装一个ICommand实现叫法通常是RelayCommand或DelegateCommand。它的作用是把一段要执行的逻辑一个方法包装成命令对象让View在绑定时不用关心逻辑具体在哪定义。一个够用的RelayCommand实现如下public class RelayCommand : ICommand { private readonly Actionobject? _execute; private readonly Predicateobject?? _canExecute; public RelayCommand(Actionobject? execute, Predicateobject?? canExecute null) { _execute execute ?? throw new ArgumentNullException(nameof(execute)); _canExecute canExecute; } public bool CanExecute(object? parameter) { return _canExecute?.Invoke(parameter) ?? true; } public void Execute(object? parameter) { _execute(parameter); } public event EventHandler? CanExecuteChanged { add CommandManager.RequerySuggested value; remove CommandManager.RequerySuggested - value; } }CanExecuteChanged直接用CommandManager.RequerySuggested来接收刷新通知这是WPF内置的一个机制每当界面发生交互比如鼠标移动、键盘输入WPF会主动询问所有命令的CanExecute从而刷新按钮的可用状态。对大多数场景来说用这个事件就够用了。稍后我会专门讲CanExecute相关的坑这里先记住这个实现。2.3 一个可以跑的示例登录窗口的完整绑定链路把上面的基类和命令组合起来一个登录窗口的ViewModel长这样public class LoginViewModel : ViewModelBase { private string _userName string.Empty; private string _password string.Empty; private string _message string.Empty; private bool _isLoggingIn; public string UserName { get _userName; set SetProperty(ref _userName, value); } public string Password { get _password; set SetProperty(ref _password, value); } public string Message { get _message; set SetProperty(ref _message, value); } public bool IsLoggingIn { get _isLoggingIn; set { if (SetProperty(ref _isLoggingIn, value)) { LoginCommand.OnCanExecuteChanged(); } } } public RelayCommand LoginCommand { get; } public LoginViewModel() { LoginCommand new RelayCommand(ExecuteLogin, CanLogin); } private bool CanLogin(object? parameter) { return !string.IsNullOrWhiteSpace(UserName) !string.IsNullOrWhiteSpace(Password) !IsLoggingIn; } private async void ExecuteLogin(object? parameter) { IsLoggingIn true; try { // 模拟登录校验 await Task.Delay(500); Message UserName admin Password 123456 ? 登录成功 : 用户名或密码错误; } finally { IsLoggingIn false; } } }对应的XAML绑定是这样以DataContext已设为LoginViewModel为例StackPanel Width300 TextBox Text{Binding UserName, UpdateSourceTriggerPropertyChanged, ValidatesOnNotifyDataErrorsTrue} / PasswordBox x:NamePasswordBox / CheckBox Content记住我 / TextBlock Text{Binding Message} ForegroundRed / Button Content登录 Command{Binding LoginCommand} IsEnabled{Binding IsLoggingIn, Converter{StaticResource InverseBooleanConverter}} / /StackPanel这个示例里有个细节值得展开PasswordBox的Password属性不是依赖属性不能直接绑定所以我在XAML里给PasswordBox起了个名字然后在code-behind里把它和ViewModel手动关联起来。具体的做法是在窗口构造函数或Loaded事件里写一句((LoginViewModel)DataContext).Password PasswordBox.Password;或者在登录命令执行时从控件层主动取值传入。很多MVVM框架会为PasswordBox提供附加属性绑定但那是框架层面做的事。基础篇里我建议你直接用PasswordBox的名字关联简单直接别在密码框绑定上过度工程设计。还注意到我把Button.IsEnabled单独绑定到了IsLoggingIn上同时CanLogin里也判断了IsLoggingIn。这里之所以要双保险是因为CanExecute的刷新时机不一定及时而IsEnabled的直接绑定是立刻生效的。这是实战中一个很常见的双写法逻辑上是冗余的但用户体感更好——按钮的禁用状态在点击后马上变化而不是等下一次界面刷新才变。3. 绑定真正跑通以后你还会遇到的几个现实问题手写的基础设施能跑通一个Demo但真正用在一个完整项目上你会碰到几个绕不开的现实问题DataContext是怎么传递到用户控件里的ComboBox控件的事件怎么转成命令输入校验怎么做才算合格这些问题的答案决定了你的ViewModel是看起来MVVM还是真正MVVM。3.1 DataContext的传递规则从窗口到用户控件MVVM的绑定核心是DataContext它决定了绑定表达式中未显式指定源时去哪个对象上找属性。DataContext有个非常重要的特性它会沿着可视树自动向下继承。也就是说窗口的DataContext设置好之后窗口内部所有控件的DataContext默认都是同一个对象除非你在某个控件上显式重新赋值。这个继承特性用好了很方便但用户控件的场景要格外小心。比如我封装了一个UserInfoEditor用户控件希望它只负责展示和编辑用户信息而不关心外层窗口的业务逻辑。如果我在用户控件的构造函数里写了DataContext new UserInfoEditorViewModel()那外层窗口通过ElementName或者RelativeSource来绑定到这个控件内部属性的路由就被切断了。更合理的做法是用户控件不主动设置DataContext让DataContext从外部继承控件内部通过UserControl根元素绑定DataContext或者给控件定义依赖属性作为对外暴露的接口。比如UserControl x:ClassMyApp.Controls.UserInfoEditor Grid TextBox Text{Binding UserName} / TextBox Text{Binding Email} / /Grid /UserControl这样的UserInfoEditor被放在某个窗口中时它的DataContext就是窗口的ViewModel内部文本框直接绑定UserName和Email这个控件纯粹是长相状态还是宿主ViewModel的。这种做法在MVVM里叫无状态控件或数据驱动控件是最稳妥的写法。如果你确实需要用户控件自己管理一摊独立的数据比如一个自带查询逻辑的日期范围选择器那就用依赖属性做对外接口把值和回调通过依赖属性暴露给外部控件内部维护自己的DataContext。两种方式都行关键是选一种并坚持用不要一会儿继承、一会儿内部新建那样用不了几层就会把自己绕晕。3.2 事件转命令ComboBox、PasswordBox这类控件怎么处理Button天生有Command属性但其他控件没有比如ComboBox的SelectionChanged、TextBox的TextChanged、PasswordBox的PasswordChanged。你当然可以在code-behind里写事件处理器然后在处理器里调用ViewModel的方法这个办法不违反MVVM原则因为code-behind里不涉及业务逻辑只是把事件转发给ViewModel。但事件转发代码多了以后code-behind会重新变臃肿而且无法用Command统一管理可执行状态。更优雅的方案是用System.Windows.Interactivity或Microsoft.Xaml.Behaviors里的EventTrigger加自定义Action把普通事件转换成命令ComboBox ItemsSource{Binding UserTypes} i:Interaction.Triggers i:EventTrigger EventNameSelectionChanged local:EventToCommand Command{Binding UserTypeChangedCommand} / /i:EventTrigger /i:Interaction.Triggers /ComboBoxEventToCommand是一个自定义TriggerAction核心实现就是订阅事件、在事件回调里执行绑定的命令。代码大概几十行很多MVVM框架已经内置了。我这里想说一个观点行为Behavior技术本身没问题但要克制使用。我见过有人把窗口的Loaded、Closing、SizeChanged、鼠标移动全变成命令ViewModel里堆了一堆跟业务毫无关系的界面响应方法。实际上像Loaded做初始化、Closing做清理这种事写在code-behind里反而是更清晰的选择因为这两件事本来就是View的生命周期行为硬转成命令没有任何好处。行为转命令的正确使用场景是这个事件确实是用户业务操作的入口且需要在多个地方复用比如列表项点击、拖放完成。3.3 数据验证IDataErrorInfo和INotifyDataErrorInfo怎么选属性校验是MVVM里绕不开的话题。WPF里有两套内置的验证接口IDataErrorInfo单属性/类级别验证老牌和INotifyDataErrorInfo支持异步验证、多错误收集、WPF 4.5引入。我的建议是新项目优先用INotifyDataErrorInfo它表达力更强而且能很好地配合async/await做异步校验比如用户名查重这种需要请求后台的操作。一个小型实现骨架public class CustomerViewModel : ViewModelBase, INotifyDataErrorInfo { private readonly Dictionarystring, Liststring _errors new(); public bool HasErrors _errors.Count 0; public event EventHandlerDataErrorsChangedEventArgs? ErrorsChanged; public IEnumerable GetErrors(string? propertyName) { if (propertyName is null) return _errors.Values.SelectMany(x x); return _errors.TryGetValue(propertyName, out var errors) ? errors : Enumerable.Emptystring(); } private void ValidateProperty(string propertyName, Liststring errors) { bool changed false; if (errors.Count 0) { if (!_errors.TryAdd(propertyName, errors)) { _errors[propertyName] errors; } changed true; } else if (_errors.Remove(propertyName)) { changed true; } if (changed) { ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName)); OnPropertyChanged(nameof(HasErrors)); } } }在SetProperty之后调用ValidateProperty把propertyName对应的一组校验规则跑一遍收集错误消息写入字典再触发ErrorsChanged。XAML这边不用写验证逻辑只需在绑定上加ValidatesOnNotifyDataErrorsTrue默认就是trueWPF会自动显示红色错误框。如果你用了DataGrid单元格校验也是吃这套机制的所以把验证做扎实了表单页面的很多问题都能提前兜住。4. 什么时候该上Prism框架的价值和代价手写基础设施能跑通但功能有限缺少模块化、缺少导航管理、缺少依赖注入容器、缺少Region机制。当你开始做一个真正的多窗口业务系统时项目的复杂度会倒逼你考虑引入更完整的MVVM框架。目前WPF圈子最主流的就是Prism热词里也反复出现所以这里单独开一章聊。4.1 Prism帮我们做了什么Prism干的活可以概括为四件事依赖注入容器在应用启动时注册接口与实现的映射窗口、ViewModel、服务都交给容器管理。构造函数里声明需要什么服务容器就自动传进来不用再到处new。导航体系把打开一个窗口和在一个区域内切换视图统一抽象成导航请求支持导航参数、确认离开拦截、返回导航解决了传统窗口跳转时状态传递难的问题。Region机制页面里划定一个或多个Region占位区域运行时把具体的View注册进Region。它把壳和内容拆开主窗口不需要知道侧边栏里放的是哪个页面这对插件式框架非常关键。ViewModelLocator自动把View关联到对应的ViewModel不用手动在XAML里每个窗口写DataContext new XxxViewModel()。这四件事里依赖注入和导航是你日常写代码最直观感受到的。举个例子传统写法里窗口A要打开窗口B并传参数可能直接在按钮命令里new WindowB() { DataContext new BViewModel(参数) }窗口一多就变成蜘蛛网。Prism的导航写法是_regionManager.RequestNavigate(ContentRegion, OrderEdit, param)谁打开谁、带什么参数都集中在导航框架里管可控性提升了一大截。4.2 模块化与Region大型应用的地图Prism的模块化设计是它相比手写方案差异最大的部分。一个模块就是一个独立的功能区比如订单模块库存模块报表模块。每个模块有自己的Module类在模块初始化时把自己包含的View注册到Region里public class OrderModule : IModule { public void OnInitialized(IContainerProvider containerProvider) { var regionManager containerProvider.ResolveIRegionManager(); regionManager.RegisterViewWithRegion(MainRegion, typeof(OrderListView)); } public void RegisterTypes(IContainerRegistry containerRegistry) { containerRegistry.RegisterForNavigationOrderEditView, OrderEditViewModel(); } }这样主程序只负责搭壳各个模块独立开发、独立发布主界面像一个插槽哪个模块往里面插主界面就显示哪个模块的内容。热词里有人问在弹出的用户控件内定义的Region注册不上这属于Prism新手几乎必踩的坑。原因是你弹窗里的用户控件如果不在主区域的加载逻辑里RegionManager默认是找不到它的。解决思路有两个方向给弹窗的RegionManager赋值通常是先RegionManager.SetRegionManager(popupWindow, regionManager)再RegionManager.UpdateRegions()。改用IRegionManager.CreateRegionManager()为弹窗创建一个全新的Region管理器单独注册Region。我的建议是第二种因为弹窗经常是独立生命周期让它的Region和主窗口共享同一个管理器反而容易埋下命名冲突的隐患。如果你刚接触Prism这个知识点建议记下来。以后碰到类似问题先想清楚这个View属于哪个Region、这个Region由哪个RegionManager管理排查方向就清晰了。4.3 自己写还是用框架我的选型建议很多新手会纠结是不是必须用Prism才算MVVM我的答案很明确不是。项目的技术选型要看团队规模和项目复杂度。我的经验性判断标准是这样的项目情况建议工具类小软件、个人项目、演示Demo手写基础MVVM设施别引入Prism2-3人小团队、业务中型系统手写或搭配轻量框架如CommunityToolkit.Mvvm5人以上团队、多模块并行、需要插件化扩展直接上Prism并配合模块化设计已有长期维护的传统WPF项目逐步改造先引入ViewModelLocator和命令封装再考虑全局重构这里补充一个重要观点MVVM框架不是银弹它只解决结构问题不解决屎山代码问题。上了Prism之后如果团队成员还是在ViewModel里堆上千行业务逻辑把后台接口调用、数据转换、UI状态全混在一个类里那Prism只会让屎山变得更高更大因为它提供了更多的位置让你继续随手放代码。框架是约束不是救赎。5. 那些年我在MVVM里踩过的坑这部分是实战里最容易让人头疼的几个问题每一个都真实地消耗过我大量调试时间。整理出来希望能帮你少走弯路。5.1 CanExecute不刷新命令按钮失灵的真相前文提到用CommandManager.RequerySuggested来实现CanExecuteChanged这个方案有一个非常隐蔽的坑RequerySuggested只在用户交互鼠标移动、键盘输入、焦点改变时触发如果你的命令可用性依赖的状态不是由用户操作改变的——比如异步加载数据完成后、定时器触发后、后台线程更新数据后——按钮的可用状态不会自动刷新。此时界面看起来就是按钮失灵实际上CanExecute没有重新求值。解决方案有三种在ViewModel里手动触发CommandManager.InvalidateRequerySuggested()强制WPF重新查询所有命令的CanExecute。在RelayCommand里加一个OnCanExecuteChanged()方法在属性变化时主动触发前面登录示例里IsLoggingIn的setter就是这么做。使用AsyncRelayCommand这类自带状态感知的命令实现它在IsRunning状态切换时主动刷新CanExecute。第三种方案是现代MVVM框架比如CommunityToolkit.Mvvm里的AsyncRelayCommand的做法也是我最推荐的。当你在命令里执行异步操作时不要让命令的CanExecute去想当然依赖别的属性直接用命令自带的IsRunning状态来控制按钮禁用既简单又可靠。5.2 用户控件内Region注册不上的排查思路这个问题我前面提过一嘴这里完整展开。现象是主窗口的Region正常工作但某个UserControl内部定义的ContentControlRegion怎么RegisterViewWithRegion都没反应。我见过的原因基本是这几类Region管理器不对RegisterViewWithRegion注册到的是默认的RegionManager但用户控件里那个Region可能绑定到了一个新建的RegionManager上。排查方法在用户控件的Loaded事件里断点查看它的RegionManager看和你注册用的是不是同一个。注册时机太早如果用户控件本身的DataContext还没初始化或者控件尚未加载到可视树RegionManager里根本还没有这个Region定义。此时调用注册就会失败或注册到错误目标。解决确保注册逻辑在控件Loaded之后再执行。Region名重复用户控件里的Region和主窗口某处Region用了同一个RegionNameRegion管理器会混淆注册到了另一个Region上。这在新手项目里尤其常见因为大家都喜欢把Region起名叫ContentRegion。排查这套问题的顺序是先确认RegionName全局唯一然后断点看RegionManager.Regions里有没有这个名字最后确认注册命令执行时Region已经存在。按这个顺序走基本十分钟能定位。5.3 DataGrid列显示完整内容绑定与模板分离的做法热词里有wpf datagrid.columns 鼠标放在上面 显示完整内容这是DataGrid最常见的需求之一列宽有限内容太长只显示一部分希望鼠标悬停时看到完整文本。新手写法是在MouseEnter事件里取单元格内容赋给ToolTip代码又长又容易出bug。其实WPF自带一套很优雅的机制如果单元格里的TextBlock被截断你只需要给TextBlock设置一个ToolTip并让它显示自身的Text在绑定阶段就用RelativeSource自引用DataGridTextColumn Header备注 Binding{Binding Remark} DataGridTextColumn.ElementStyle Style TargetTypeTextBlock Setter PropertyTextTrimming ValueCharacterEllipsis / Setter PropertyToolTip Value{Binding RelativeSource{RelativeSource Self}, PathText} / /Style /DataGridTextColumn.ElementStyle /DataGridTextColumn这样每一行的TextBlock会自动把当前文本作为ToolTip完全不需要C#代码。注意这里关键点是ElementStyle而不是CellStyle前者作用于内部的TextBlock后者作用于整个单元格容器。如果你用DataGridTemplateColumn就自定义一个DataTemplate里面放TextBlock并设置同样的ToolTip绑定。这个写法的好处是纯XAML、无事件、无内存泄漏风险也是我在项目里固定的做法。5.4 绑定失效时先去输出窗口找错误最后分享一个所有WPF开发者必须养成的习惯当Binding不生效时第一反应不是翻代码而是看Visual Studio的输出窗口。WPF绑定失败会在输出窗口打印一条以System.Windows.Data Error开头的警告里面明确告诉你绑定路径、源对象、目标元素和失败原因。比如Binding路径拼错了输出窗口会告诉你找不到名为UserName的属性DataContext为null它会告诉你源为null。大部分绑不上的问题瞄一眼输出窗口就有答案。这不是什么高深技巧但确实很多人不知道。写XAML时把输出窗口开着可以省掉大量无谓的Debug时间。6. 一点个人经验MVVM不是银弹但确实值得学做WPF开发这几年我的体会是MVVM最大的价值不是代码变少了而是变稳了、变清楚了。同一个界面用事件驱动写可能一百行用MVVM写也是几十行数量上不一定会赢但你能一眼看出这个界面的数据来源是什么、交互逻辑在哪定义、数据怎么流转。这对团队协作和后期维护来说是决定性的。我见过不少人是被迫用MVVM的——模板生成的代码默认就是ViewModel结构于是一边在ViewModel里写属性一边在事件里操作控件两套风格混在一起越改越乱。要入门MVVM心态上要接受两件事一是属性会变多很多在事件时代随手声明的局部变量现在都要提升为ViewModel的属性和命令二是绑定是声明式的你需要把一部分控制权交给WPF的绑定引擎而不是事必躬亲。如果你正处在学习阶段我的建议是先把手写的那套MVVM基础设施用在一个实际的小项目上——随便什么工具软件都行——把绑定、命令、验证、DataContext传递这套基本功练到条件反射级别再去看Prism的官方文档就会觉得很多概念都是顺理成章的事。MVVM本身不难难的是一开始就建立正确的分层意识。有了这个意识后续学什么框架、用什么工具都只是时间和熟悉度的问题。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。