很多事只是最初看起来有意义,但经过多次重复就慢慢失去了意义。
今天封装一个简易动态表单组件的时候遇到了一个的 Bug 分享下,避免踩坑。我使用动态组件来加载一些表单组件,对于大多数组件来说,插槽(slot)是不必要的,但有些控件则需要。比如 el-select
,需要 slot
来挂载选项
1 | <Component |
在 Component
渲染为 select
的时候单独处理 el-option
,其他组件的时候则不渲染。这样看着似乎没有任何问题,因为我们的认知中 v-if
是不会渲染这部分内容的,但是当 Component
渲染为 Cascader
组件的时候,展开 Cascader Options
发现是空白的
一开始毫无头绪,完全不知道哪里出了问题,代码看着也很正常,也没有报错信息。于是我就去 element-plus
Github 翻 issues
,果然还真有人遇到了一样的问题,这里有个搜索 issues
技巧
可以根据你的问题 关键字
去快速搜索到目标 issue
https://github.com/element-plus/element-plus/issues/5428
可以看到和我遇到的是同样问题
这个回答说 <slot>
引起的,当我把 Component
options 删掉后,果然 Cascader
正确渲染出来了,看来果然是 <slot>
引起的
这里不能直接注释掉 options,因为 注释
也是一个元素,具体原因接着往下看
可以看到你即便是注释掉它也会占据 default slot,接着看下面一个回答
可以看到 Cascader
组件的源码 renderLabel
这里接收了一个 default slot
,再来说说上面的为什么加了 v-if
后它还是被当做 default slot
渲染了,其实很简单,你只需要检查开发工具上的元素即可看到图中 v-if
是个注释标签,他被渲染进了一个 class 名为 el-cascader-node__label
span 标签中,阻挡了默认内容
我们期望的渲染应该是这样的
在 Cascader
组件源码里也能找到同样的做法
这个注释的意思是在 el-radio
组件内部使用一个空的 <span />
元素进行占位。
解释:
在 Vue
中,如果你想要防止一个组件渲染其默认插槽的内容,但又不想完全移除这个插槽(可能是因为样式或脚本上的需要),你可以插入一个空的元素来“占位”。这里使用了一个空的 <span />
元素。
打开这个 #2485 pr 找到 #2347 这个 issue,它里面也有一个 v-if
注释,跟我们的情况一样,同样是阻止了插槽组件的默认插槽渲染。这下明白了吧,包括注释也会阻止插槽组件使用默认值
另外这个 issue 的标题为:任何内容都会阻止插槽组件使用默认值#2347,标题已经告诉我们答案了,即任何内容都会阻止插槽组件的 default slot
渲染,包括注释
再看这个回答,他给出了两个环境示例(可以自行去查看)
global 环境下默认插槽没有被渲染出来
prod 环境下则渲染出来了,这个回答里也明确说了生产环境下注释会被删掉,自然就能渲染出来了
(所以这只是一个 dev 下的 bug ?)
知道了根本原因就好办了。
接下来回到我们的动态表单组件中去,我能想到最简单最稳妥方法就是单独处理 select
这类组件了,其他组件照常渲染。
(如果还有更好的解决方案请在评论区指指点点我)
最后,2024 新年快乐
]]>本文针对 Win11 操作系统的 PowerShell。其实文档已经写的很清楚了,本文是为了方便萌新快速上手,如果你对文档已经很熟悉了,那么这篇文章可能对你来说没什么用。
打开 Oh My Posh,找到 Docs 页,选择 Windows 菜单
打开 PowerShell 终端并运行以下命令:
1 | winget install JanDeDobbeleer.OhMyPosh -s winget |
这会安装一些东西:
oh-my-posh.exe
- Windows 可执行文件themes
- 最新的 Oh My Posh 主题安装完成后重启终端
打开 Fonts 菜单。
为什么需要 Nerd 字体?因为 Oh My Posh 的主题里用的 Icons 都是需要 Nerd 字体支持,否则会显示乱码
访问 https://www.nerdfonts.com/ 可以看到Nerd 字体支持众多 Icons
在 PowerShell 运行以下命令:
1 | oh-my-posh font install --user |
这里不加 –user 的话就用管理员身份打开 PowerShell 执行这条命令,这个命令是一个 Nerd 字体的 CLI
这里我选择安装 FiraCode 字体,想安装其他字体请自行选择
如果觉得上面命令 CLI 没有体现字体具体样式可以进入 Downloads 页面自行安装各种 Nerd Font,看自己喜好,下载好后解压安装即可
安装完字体后要在终端中启用选项 Use the new text renderer (AtlasEngine)
打开终端的设置- 呈现 - 打开 - 保存
字体安装完要配置后才能使用:在终端上按住(快捷方式: CTRL + SHIFT + ,
)会自动打开 settings.json
文件,在 profiles
中的 defaults
属性下添加 font.face
属性:这里指定你安装的字体即可
具体可看:https://ohmyposh.dev/docs/installation/fonts#configuration
https://ohmyposh.dev/docs/installation/prompt
如果您不知道当前使用的是哪个 shell,Oh My Posh 有一个实用程序开关可以告诉您这一点。
终端执行这个命令:
1 oh-my-posh get shell
我这里是 PowerShell 我就按照 PowerShell 的文档来
编辑 PowerShell 配置文件脚本
1 | notepad $PROFILE |
当上述命令出现错误时,请确保首先创建配置文件
1 | New-Item -Path $PROFILE -Type File -Force |
在这种情况下,PowerShell 也可能会阻止运行本地脚本。要解决此问题,请将 PowerShell 设置为仅要求使用 : 1
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine
然后再执行
1 | notepad $PROFILE |
会打开一个 PowerShell_profile.ps1
一个文本文件
添加以下行
1 | oh-my-posh init pwsh | Invoke-Expression |
添加后,重新加载以使更改生效。
1 | . $PROFILE |
再次打开 PowerShell 终端就会显示默认的主题终端了
https://ohmyposh.dev/docs/themes
在 PowerShell,中可以使用以下 PowerShell cmdlet 显示每个可用主题。
1 | Get-PoshThemes |
也可以直接在 Theme 找到自己想要的主题后,复制主题名字,这里我选择的是 neko
主题
打开文档的 Customize 菜单项,这里是设置配置/主题 脚本文件
在终端中执行以下命令,打开的就是刚才的 PowerShell_profile.ps1
脚本文件
1 | notepad $PROFILE |
里面默认内容是 oh-my-posh init pwsh | Invoke-Expression
根据文档说明要设置新的配置/主题,您需要更改 profile
或 .<shell>rc
脚本中 oh-my-posh init <shell>
行的 --config
选项,并将其指向预定义主题或自定义配置的位置。他处理两种可能得值一个是本地配置文件路径和远程配置URL,这里选择远程的配置即可,以下内容覆盖到配置文件中去
1 | oh-my-posh init pwsh --config 'https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/neko.omp.json' | Invoke-Expression |
如果要更换其他主题直接替换圈中的 Json
名即可
更改后,重新加载你的个人资料以使更改生效。
1 | . $PROFILE |
再次打开终端可以看到主题更新了
]]>在 TypeScript 中,never
本质就是一个空集。事实上,在另一个流行的 JavaScript 类型系统 Flow 中,作用完全一样的类型直接被命名为 empty。
由于集合中没有值,never
类型字面意义地永远不会(双关)有任何值,包括 any
类型的也不在 never
这个空集中。这就是为什么 never
有时也被称为 uninhabited 类型或 Bottom 类型。
Bottom 类型是 TypeScript 手册 对它的定义。当我们把 never
放一个完整类型层次的树形结构图之后,看起来才比较有意义。
就像在数学中我们使用零来表示没有的数量一样,我们需要一个类型来表示类型系统中的不可能。
由于我们永远无法给一个 never
类型赋值,因此我们可以使用它来对函数参数施加限制。
比如这个 log
函数的参数,他可以赋值任何类型,但是唯独不能赋值日期类型
1 | // x 可以是任何类型,但不能是日期 |
第一时间想到的是,我们可以写一个判断,如果 x 是类型日期就抛出一个错误
1 | function log(x) { |
但是你这样子做,也没毛病,就是用一点也不 TS,你把这个错误的发生事件延迟到了运行时,也就意味着你在编写代码的时候,在运行之前你是收不到任何的错误提示的
可以看到上图中 TS 是不会报错的,只有当你运行了代码的时候你才发现这个错误发生了
那么我们能不能运用 TS 的能力把这个错误提前到编译时呢?答案是当然可以,这就是 TS 的主要功能之一
现在我们把这个判断去掉,给 log 加上一个泛型 T,有了这个泛型参数 T 之后,我们可以对这个参数 x 进行进一步的约束,我们可以使用一个三目运算,看一下这个类型 T 是不是日期,如果说他是日期类型的话,那么这个 x 的类型就给他标注为 never 类型,就是绝不可能的类型,否则的话就是参数 T
这样我们会发现当你给他传一个 Date 的时候,他的类型约束这里就满足 never 了,这个 x 就变成一个 never 类型,而 never 类型是不能接受一个 Date 类型的
这样写比较丑可以把这个类型给提出去
而且这个类型是通用的,任何需要去除 Date 类型都是可以使用 BanDate<T>
的
1 | type BanDate<T> = T extends Date ? never : T; |
把错误提到编译时是 TS 的主要功能,得益于 TS 的类型检查,我们就可以尽早的发现这个问题,不能传递日期
我们甚至可以更近一步,把这个类型做的再通用一点,可以去除掉任何类型
这样我们就做出来一个更佳通用的类型
]]>Blazor 框架是一个用于构建单页应用程序的开源框架。它由 Microsoft 创建,将传统的 razor 框架与现代的 .Net 和 WebAssembly 框架相结合。更重要的是?它有助于构建服务器端和客户端应用程序。Blazor 一般有两种托管模型,一种用于客户端,另一种用于服务器端
Blazor WebAssembly 使用 WebAssembly 技术将 C# 代码编译成可在浏览器中运行的二进制文件,然后在客户端执行,这种模型可以提供更接近原生应用程序的性能和用户体验。这样,前端和后端的代码都可以使用 C# 来编写,实现了一种统一的开发语言和技术栈。这意味着应用程序的逻辑和 UI 都在客户端执行,而不需要与服务器进行实时通信,但需要考虑到文件大小和加载时间的影响。
Blazor WebAssembly 可以理解为一种单页应用程序(SPA)的实现方式,通过在客户端进行渲染和执行来提供交互性和响应性的用户体验,类似于使用前端框架(如Vue.js、React、Angular)实现客户端渲染的现代 Web 开发趋势。
优点:
• 这允许客户端站点在浏览器中运行应用程序。下载应用程序后,您可以断开服务器连接。但是,应用程序恢复工作,但无法与服务器交互以提取新数据。
• 这种托管模式的最佳之处在于它可以充分利用客户的能力和资源。
• 它不需要ASP.NET core Web 服务器来托管应用程序。
• 它通过缩短加载时间显着降低了服务器负载,因为它只需要在 DOM 中进行修改。
缺点:
• 此托管模型仅限于浏览器功能。
• .NET 标准兼容性及其调试存在限制。
• 需要兼容Wasm 的客户端软件和硬件。这意味着它仅支持最新的浏览器。
Blazor Server 使用 SignalR 技术在客户端和服务器之间建立实时的双向通信。在 Blazor Server 中,应用程序的 UI 是在服务器上渲染的,然后通过 SignalR 将更新的 UI 推送到客户端。这种模型可以减少客户端的资源消耗,但需要保持与服务器的实时连接,无法脱机使用。
Blazor Server 是服务端渲染的一种实现方式。在现代 Web 开发中 NuxtJS 也是一种服务端渲染框架,
Nuxt.js 是一个基于 Vue.js 的框架,它使用 Node.js 在服务器上进行渲染,并生成静态的 HTML 文件。这种模型可以提供更好的性能和 SEO,但需要在服务器上进行渲染,并且不支持实时的双向通信。
JSP(JavaServer Pages)和传统的 Java 后端项目也是服务端渲染,在这种架构中,前端和后端的代码是紧密耦合的,前端页面(JSP)和后端逻辑(Java)在服务器端进行渲染和执行。
在传统的 JSP 和 Java 后端项目中,前端页面包含了动态的 Java 代码片段,这些代码片段会在服务器端被执行,并生成最终的 HTML 页面。然后,服务器将生成的 HTML 页面发送给客户端浏览器进行显示。
它们都是 服务端渲染 只是实现方式不同
优点:
• Blazor Web 应用程序可以更快地加载,因为服务器端会预先呈现 HTML 内容。
• 它可以有效地利用服务器的功能。
• 服务器端应用程序没有任何浏览器版本限制。因此,它甚至可以使用旧的浏览器。
• 服务器端托管模型的优点是它增强了安全性,因为它不会将应用程序代码传输到客户端。
缺点:
• 它要求与服务器的连接必须处于活动状态。如果没有互联网连接,Web 应用程序无法运行。
• 此托管模型需要ASP.NET core 服务器。
• 由于数据不断地往返于客户端服务器,因此它具有显着的延迟。
本项目使用的 Blazor Server 模式,所以只讲 Blazor Server 目录结构
Program.cs
:应用的入口点,用于设置 ASP.NET Core 主机 并包含应用的启动逻辑,其中包括服务注册和请求处理管道配置:
配置应用的请求处理管道:
MapFallbackToPage("/_Host")
以设置应用的根页面 (Pages/_Host.cshtml
) 并启用导航。appsettings.json
和环境应用设置文件:提供应用的配置设置。
_Imports.razor
:包括要包含在应用组件 (.razor
) 中的常见 Razor 指令,如用于命名空间的 @using
指令。
App.razor
:应用的根组件,用于使用 Router 组件来设置客户端路由。 Router 组件会截获浏览器导航并呈现与请求的地址匹配的页面。
wwwroot
文件夹:应用的 Web 根目录文件夹,其中包含应用的公共静态资产。
Shared
文件夹:可以理解为前端的项目目录的 Layout 文件夹,里面放的是布局组件。Tools
文件夹:工具类写在这里以供 组件 随时调用。
Locales
文件夹:存放国际化映射文件。
Properties
文件夹:在 launchSettings.json
文件中保存开发环境配置。
Pages
文件夹:包含 Blazor 应用的可路由 Razor 组件 (.razor
) 和 Blazor Server 应用的根 Razor 页。 每个页面的路由都是使用 @page
指令指定的。
_Host.cshtml
:实现为 Razor 页面的应用根页面:
App
组件 (App.razor
) 的呈现位置。Data
文件夹:包含 WeatherForecast
类和 WeatherForecastService
的实现,它们向应用的 FetchData
组件提供示例天气数据,calss 和 service 放到这里面给 pages 组件提供数据,其中 class 的类型要和后端的返回 Model Class
数据类型一致才能正确接收数据
Blazor应用基于组件。 Blazor 中的组件是指 UI 元素,例如页面、对话框或数据输入窗体。
组件类通常以 Razor 标记页(文件扩展名为 .razor
)的形式编写。 Blazor 中的组件正式称为 Razor 组件,非正式地称为 Blazor 组件。 Razor 是一种语法,用于将 HTML 标记与专为提高开发人员工作效率而设计的 C# 代码结合在一起。 借助 Razor,可使用 Visual Studio 中的 IntelliSense 编程支持在同一文件中的 HTML 标记与 C# 之间切换。
lazor 使用 UI 构成的自然 HTML 标记。 以下 Razor 标记演示了当用户选择按钮时递增计数器的组件。
写过前端的很容易理解下面的结构,上面写template
,@code{}
内写 C# 代码逻辑
1 | <PageTitle>Counter</PageTitle> |
将 CSS 样式隔离到各个页面、视图和组件以减少或避免:
若要定义组件特定的样式,请在相同文件夹中创建一个 .razor.css
文件,该文件与组件的 .razor
文件的名称相匹配。 .razor.css
文件是限定范围的 CSS 文件,和 vue 组件中的 scope
一个意思。
详情可看:
1 | <style scoped> |
一般用于对子组件应用样式更改
默认情况下,CSS 隔离仅应用于与
{COMPONENT NAME}.razor.css
格式关联的组件,其中占位符{COMPONENT NAME}
通常是组件名称。 若要对子组件应用更改,请对父组件的.razor.css
文件中的任何后代元素使用::deep
pseudo-element。::deep
pseudo-element 会选择属于元素生成范围标识符后代的元素。
示例:更改 Bootstrap Blazor 的 Card
组件 默认 padding 值
可以看到 card 的 默认 padding 被覆盖成了我们自定义的 padding
写过 Vue 的应该很熟悉了,除了语法上的区别,其他的基本一样。默认情况下,组件的属性是私有的,这里需要注意子组件的 Value 属性要声明为 public,以便父组件能够传递数据给子组件
Parent.razor
:
1 | <div class="grayscale"> |
Child.razor
:
1 | @foreach (var item in Value) |
只要父组件和子组件在同一级目录中就能自动识别,直接在父组件中调用子组件即可无需像 JS 那样手动 import
后再注册使用
和 es6 模板字符串不能说毫不相干简直一模一样
1 | string apiUrl = $"api/RejudgeRecord/rejudgeRecord/list?startTime={startTime}&endTime={endTime}&timeGranularity={timeGranularity}"; |
动态 class
1 | <div class="@GetClassByJudgeCode()"></div> |
1 | // 动态样式 |
动态 style
1 | <div style="@SetFilterActiveButton("-3")"> -3 </div> |
1 | private string SetFilterActiveButton(string buttonValue) |
blazor 组件文档:
在Blazor中,@
符号用于表示 C# 代码的起始点。当您在Blazor组件中使用 @
符号时,它会告诉 Blazor 编译器将其后面的内容视为 C# 代码而不是普通的文本。Razor 通常很聪明,可猜出你何时切换回 HTML。 例如,以下组件使用当前时间呈现 <p>
标记:
隐式:@
1 | <p>@DateTime.Now</p> |
显式:@()
1 | <p>@(DateTime.Now)</p> |
对于复杂的表达式,涉及到了多个操作和方法调用。在这种情况下,为了明确告诉 Razor 引擎将其作为 C# 代码进行求值,您需要使用 @()
将整个表达式括起来。
将 value.Value 强转成 DateTime 类型再调用 ToString 格式化为 “yyyy-MM-dd HH:mm:ss” 格式
1 | <div>@(((DateTime)value.Value).ToString("yyyy-MM-dd HH:mm:ss"))</div> |
1 | @if (value % 2 == 0) |
1 | <ul> |
官方文档的双向绑定:
看看官方文档示例:
再看看 Vue 的 v-model 官方文档示例:
不能说一模一样只能说 99 像了,所以无缝入门
可以先看看官网的示例:
子组件:
PasswordBox.razor
这是接收父组件传过来的值,和 vue 里 props 一个意思
1 | [ ] |
将 Password
变量绑定在 input value 属性上,通过 OnPasswordChanged
事件每次输入都会把当前最新的值赋值给 Password
要想正确通知父组件更新需要声明一个 EventCallback<T>
,用于组件触发事件并在值发生变化时通知父组件,在这种情况下,PasswordChanged
是一个事件回调,它提供了 InvokeAsync()
方法,用于调用事件回调并将更新后的 Password
值传递给父组件
1 | [ ] |
注意:EventCallback
的命名一定要 {Parameter}Change
,这是一种约定,否则不生效
1 | Password: <input |
父组件:
parent.razor
通过使用 @bind
指令,它将 PasswordBox
组件内部 Password
属性 与父组件 password
变量进行双向绑定
这意味着当密码框的值发生变化时,password
变量的值也会更新,反之亦然
1 | <PasswordBox @bind-Password="password" /> |
在日常开发中封装组件一般会基于 UI 库的组件进行二次封装,这个时候该如何双向绑定,可以参考以下示例
子组件:
CustomInput.razor 组件基于 Bootstrap Blazor 的 Select
组件二次封装
代码的关键是 _RejudgeCode
字段,他重写了自己的 get set 方法,获取值执行 get 返回 RejudgeCode
字段值,set _RejudgeCode
字段值时,会将新的值赋给 RejudgeCode
字段,并通过 RejudgeCodeChanged.InvokeAsync(RejudgeCode)
来异步地触发 RejudgeCodeChanged
事件回调,通知父组件,RejudgeCode
的值已经发生了变化
1 | <div> |
1 | /// <summary> |
父组件:
parent.razor
父组件把表格 Row.RejudgeCode
字段传给 CustomInput
组件后,由于子组件内部的 select 组件绑定的是 ComputedRejudgeCode
,它就会执行我们自定义的 get
方法并返回 RejudgeCode
给 select 组件,当执行 select change 事件的时候 ComputedRejudgeCode
值改变了就会执行自身的 set 方法,把最新的值赋值给 RejudgeCode
,并把最新的 RejudgeCode
值传递给父组件
1 | <TableColumns> |
捕获对组件或 HTML 元素的引用 <MyDialog @ref="myDialog" />
1 | <MethodEdit @ref="@MethodEditRef" |
1 | /// <summary> |
子组件:
MethodEdit.razor
这样就可以调用 MethodEdit
组件内部的变量或者方法,注意:它们一定是 public
声明的否则父组件将无法调用
1 | /// <summary> |
Razor 组件也具有定义完善的生命周期。 组件的生命周期可用于初始化组件状态及实现高级组件行为
OnInitialized
和 OnInitializedAsync
方法用于初始化组件。 组件通常在首次呈现后初始化。 组件初始化后,可能会在最终释放前呈现多次。 OnInitialized
方法类似于 ASP.NET Web Forms 页和控件中的 Page_Load
事件。
1 | protected override void OnInitialized() { ... } |
当组件已从其父级接收参数并将值分配给属性时,将调用 OnParametersSet
和 OnParametersSetAsync
方法。 这些方法在组件初始化后以及每次呈现组件时执行。
1 | protected override void OnParametersSet() { ... } |
OnAfterRender
和 OnAfterRenderAsync
方法在组件完成呈现后调用。 此时,将填充元素和组件引用(在下文中详细介绍这些概念)。 此时已启用与浏览器的交互性功能。 与 DOM 和 JavaScript 执行的交互可以安全地进行。
1 | protected override void OnAfterRender(bool firstRender) |
在服务器上进行预呈现时,不调用 OnAfterRender
和 OnAfterRenderAsync
。
firstRender
参数在首次呈现组件时为 true
;否则,其值为 false
。
从 UI 中移除 Razor 组件时,该组件可以实现 IDisposable
来释放资源。 Razor 组件可以通过使用 @implements
指令来实现 IDispose
:
1 | @using System |
注入:
1 | @inject IJSRuntime JSRuntime |
使用之前要在 _Layout.cshtml
body 内引入 js 文件,或者在 <script> </script>
内定义好所需的 js 函数
使用:
InvokeVoidAsync
第一个参数是 func name
,第二个参数是 js func
的参数
1 | private void PositionWaferTableRow(string Id) |
参考以下文章:
有时候我们需要将有些变量放置到 appsettings.json
配置文件中,我们该如何在组件里访问配置文件里的变量
注入IConfiguration
对象:
1 | @inject IConfiguration Configuration |
上面的语句使 IConfiguration
对象在 Razor 模板的其余部分中可作为 Configuration
变量提供。
string
类型1 | <div class="system-version">版本:@(version)</div> |
1 | protected override void OnInitialized() |
1 | // appsettings.json |
bool
类型1 | var IsCheckLicense = Configuration.GetSection("IsCheckLicense").Get<bool>(); |
1 | // appsettings.json |
需要注意 List<SelectedItem>
类型问题
1 | private IEnumerable<SelectedItem> MethodItems { get; set; } = Enumerable.Empty<SelectedItem>(); |
1 | // appsettings.json |
没有在写 vue,可处处都在写 vue
]]>菜单标题过长用 tooltip 显示用户体验更好。虽然<a>
标签的 title 属性 hover 后也能显示全部 title,但是要 hover 1-2s 才显示,体验不行,另外只有菜单过长的用 tooltip 显示才更合理,即菜单有...
的
注:本项目 UI 使用的是 antdv,各个 UI 使用上区别不大,逻辑不变。
分析需求可以得到的关键点是:如何知道这个元素内容超出了元素容器,如果超过了就显示 tooltip 不超过不显示,这里你需要了解两个 DOM 元素属性:
scrollWidth
属性表示元素内容在不使用滚动条的情况下,需要的总宽度。它包括了元素内容的实际宽度以及在不换行情况下可能产生的溢出部分的宽度(元素内容总体宽度,包括被隐藏的那部分)。clientWidth
属性表示元素内容的可见宽度,不包括滚动条或边框的宽度。它是指元素内部的可视区域的宽度,不包括被溢出隐藏的内容部分(元素内容可见宽度,不包括被隐藏的那部分)。通过将这两个属性进行比较,可以判断元素是否出现了水平方向上的溢出。如果scrollWidth
大于clientWidth
,则表示元素内容超出了可见区域,然后手动控制显隐 tooltip 即可。
1 | // 用于手动控制 tooltip 显隐 |
1 | <a-menu-item |
menuTextRef
是文本元素的 ref ,用于获取scrollWidth
和clientWidth
菜单跳转使用的是<router-link>
组件,事件绑定在子标签下会无效,因为<router-link>
默认会渲染成<a>
标签来处理路由导航,而<a>
标签会阻止其内部子元素触发的大多数事件。 <a>
标签是一个具有默认行为和语义的HTML元素,其中包含了一些默认的事件处理逻辑,例如点击后进行页面导航。当你在<router-link>
的子标签上绑定事件时,这些事件很可能会被<a>
标签捕获并阻止冒泡,因此无法触发到绑定的事件处理函数。 所以 @mouseover
、@mouseleave
只能绑定在<router-link>
组件上。
1 | <router-link |
这样就达到了鼠标移入移出显隐 tooltip 效果了
菜单在折叠状态下的子菜单是用 Popover 组件展示的,既然没有了宽度限制就没有必要再使用 tooltip 来显示那些放不下的菜单了
1 | <span |
1 | // 菜单文本动态样式 |
给菜单绑定一个:style
,通过isCollapsed
来判断菜单是否在折叠状态,这里设置的宽度其实就是clientWidth
。
代码中得知菜单在展开状态下宽度是90px
(clientWidth 宽度根据自己代码进行微调),折叠状态下则将width
设置为空字符串(通常用于移除元素上已经存在的宽度限制)。通过将width
设置为空字符串,可以使元素在没有具体宽度限制的情况下自动适应内容的宽度,这样得到的结果scrollWidth === clientWidth
始终是相等的,条件不成立为showTooltip.value = false
,这样就做到了折叠不显示 tooltip 的效果了。
1 | showTooltip.value = |
很多事只是最初看起来有意义,但经过多次重复就慢慢失去了意义。
拿到大屏设计稿首先是设计页面布局,把布局中最基本的 块(项目)
定义好。这里用 flex 布局一把梭也能实现,但是没有 grid 强大和灵活,比如块(项目)
大小、间距改变,这些改起来还是相当麻烦的。如果是 grid 布局,修改模块间距就方便很多,它有一个 gap
属性,row-gap
属性设置行与行的间隔(行间距),column-gap
属性设置列与列的间隔(列间距)。
1 | .container { |
gap
属性是column-gap
和row-gap
的合并简写形式,语法如下。
如果gap
省略了第二个值,浏览器认为第二个值等于第一个值。
1 | .container { |
要修改布局的占比也很简单,下面定义的是 三行两列 容器布局,比如第一行高度占比想修改的高一点,只要修改第一个数值 grid-template-rows: 50% auto 20%;
,那第一行占比就是 50%
1 | // 声明三行两列的网格布局 |
此外 grid 还提供了两个关键字:
fr
关键字(fraction 的缩写,意为”片段”)。如果两列的宽度分别为2fr
和1fr
,就表示前者是后者的两倍。
fr
关键字可以搭配绝对长度使用
1 | .container { |
auto
关键字表示由浏览器自己决定长度,自动占满剩余长度
更改某个块(项目)
的大小:
1 | .item-1 { |
所以更推荐使用gird
布局用于整个页面的布局设计,且两者在应用场景上并不冲突,grid 处理整体布局,flex 处理局部布局才是最佳实践。
还有一些其他 API 此次开发没用到就不细说了,可以去看阮一峰的 gird 教程
tip: 有些东西没必要全部记住,用的时候再去翻就行
下面是使用 gird 布局写的一个大屏布局 demo,代码量少的同时结构也很清晰
带装饰效果的饼图,不贴代码了,直接看
还有自定义 legend,默认的 formatter 选项只有 name 这个属性,如果想显示值只能用下面这种写法了
1 | formatter(name) { |
所有的 3D环形饼图代码逻辑都是一样的,无脑 copy😁
这个对角线画原生表格的时候很有用,它不会随单元格拉伸而导致位置偏移
radial-gradient() CSS 函数创建一个图像,该图像由从原点辐射的两种或多种颜色之间的渐进过渡组成。
1 | /* 在容器中心的渐变,从红色开始,变成蓝色,最后变成绿色 */ |
后续再补充。。。
]]>我很喜欢作者的这个系列文章,干净的代码不仅可以增强代码的健壮性,也能带来愉悦的心情,让人喜欢上 coding👨💻,编写代码是一门艺术🙆♂️,因此我们需要考虑使用更合理的实现方式,而不是为了完成 “任务” 而写出一些无用的变量以及各种 if 嵌套和回调地狱🤮。这些会导致代码结构混乱、逻辑难以理解,最终可能会给未来接手代码的程序员带来麻烦,甚至会得到未来接手你代码程序员的亲口祝福🙏。所以 Don’t write “zombie code”🧟♂️!!
我非常反感那些故意写💩山代码的“程序员”,这种不负责任的行为真的让我非常生气。
虽然这个系列文章作者使用了大量的 emoji 表情(有些人可能认为专业文章不应该包含这么多表情符号,还好这并不专业🥴),但我认为这样的表达方式很有趣,因此我保留了它们。
欢迎回来大家🤓,我将继续 #CleanCode 系列,这是第 2 部分。
命名事物意味着我们如何调用我们的变量、函数和类构造函数,使它们与其适用的术语相关。
在之前的博客中📑,我们讨论了我们通常忘记遵循命名约定的重大痛点。在这篇博客中📑,我们将深入探讨命名。我们谈论开发(以及一般的编码技术)中所有可命名的东西,它们是变量、常量(和属性)、函数和方法以及类。
一般来说,我们给任何东西起名字只是为了熟悉它,给它起一个名词会让它与我们的联系更紧密🤝🏽。然而,我们确实根据它们的属性🎀、外观🎭、位置、存在等来命名。因此这些名称包含一些意义和独特性。好的和有意义的名字可以帮助我们记住事物及其功能。我们可以在需要时轻松调用这些名称。模棱两可😌和单字母名称往往会增加学习和理解变量存在的复杂性🥺,因此我们在需要时可能无法调用它。通过使用明确的名称,我们可以使代码更加健壮和易于维护。
所以命名很重要,因为我们以后可能会依赖这些名称🤔,我们不能随便给我们的变量或数据集起 any name
,如果这样我们在需要的时候甚至无法使用或找到它🧐。名称应该能够表达🏇🏽它们的含义和存在意义,而不需要通过整个代码来了解其功能和细节。
1 | // bad |
上面的代码片段中, pro
代表什么,没有专有名称,我们无法猜测变量的功能。这个问题不仅存在于编码领域,而是存在于每个领域。
在数据库管理中,无论是顺序还是非顺序的,我们给我们的模式、表格📋以及它们的不同变量命名,以便在需要时获取它们。如果我们没有考虑它们的含义而进行命名,那么在使用的时候就无法记起它们。
1 | // good |
当我们写代码的时候,就像之前文章说的,应该像你在讲故事那样🤴🏽👸🏽,所以在一个故事中,我们有情节🗺、角色🤴🏽👸🏽和情境💃🥱。角色是根据他们的特征命名的。同样地,变量、函数、方法和类是我们的角色。我们应该根据它们的特征来命名它们。例如:
1 | class User { |
类似地,在 MySQL、MongoDB 或其他数据库管理系统中,我们使用不同的列初始化架模式和表。所有这些都需要适当的名称和适当的标识,以便它们能够证明自己的存在是有意义的👌。良好命名的变量可以让 ‘读者’ 轻松地理解内容。
因此,好的命名以及强调相关性在代码和其他相关领域中都是很重要的,我们给变量或数据容器命名时需要考虑其含义。
然而,我们不可能总是给所有的变量起合适的名字,有时我们确实需要采用简称,但该简称也应该与该变量相关。
首先,我们需要考虑某些编程语言必须遵循的命名约定🈹🈚。命名约定被认为是这些编程语言的伪语法。在此,考虑使用 Name Casing 按照规定的方式命名变量。为编码和命名变量规定了四种类型的名称大小写:
名称大小写 | 示例 | 适用语言 |
---|---|---|
snake_case (蛇形命名法) | is_valid | Python, DBMS |
camelCase (小驼峰命名法) | newUser | JavaScript (JS) |
PascalCase (大驼峰命名法) | FormerClass (用于类名) | Python, Java, JS |
kebab-case (连字符命名法) | <side-drawer> (自定义 HTML 元素) | HTML |
其次,我们必须知道我们赋予其名称的 术语 / 变量。如果我们从编码的角度来看,这里我们给三个重要的 术语 / 事物 命名,它们是:
这些是存储数据或传输数据的数据容器。在这里,我们将它们用作输入数据、用于验证或用作项目列表。他们的名字应该简洁明了。在命名这些数据容器时,我们应该使用名词或带有形容词的短语。
比如说:
1 | var num; |
What do we store?(我们存储什么?) | Good Names(好的命名) | Bad Names (不好的命名) |
---|---|---|
一个用户对象(name, email, age) | userData, userEmail, email, age | us, data, container |
用户输入验证信息(boolean) | isUser, isLoggedIn, isAuth, isValid | user, login |
产品描述 | itemNum, itemId, id, address | prod, i , add, |
这些是需要执行的命令或操作。无论我们将其用于任何值还是布尔值,都应考虑到可能的结果和目标,在为这些命令命名时使用动词或简短的动词短语。但是,我们还必须遵循特定库或语言的内置方法,例如now()、strftime()等。
列如:
What do we perform?(我们要做什么?) | 操作 | 计算布尔 |
---|---|---|
user data | getUser(), sendData(), save() | isValid() |
user email | Signup(), login() | isEmailValid() |
save user, product | save() , user.store() | isPreasent(), isAlreadyExists() |
find user | allUser(), getUserByEmail() | isUserExist() |
1 | // 同样的 |
这些是我们在声明本地变量时构建的可变对象的通用模型蓝图。因此,它们也是名词。我们使用类来创建像产品、用户、请求体等内容的东西。
列如:User{}
, Product{}
, RequestBody{}
等
object model | describe object (对象描述) | more info (更多信息) | but do not like name (不要像这样命名) |
---|---|---|---|
person | user, Admin, visitor | customer, client | us, usAdmin, cl |
product | item, product, prod | meal, course, book | pro, entity, item |
container | cart, basket | fruits, books | box, bin |
request | req, request, reqBody | reqData, reqObj | req, obj, data |
例外情况💥:在代码中,有一些特定的方法用于获取或设置类的属性值。这些方法称为 Setter 和 Setter。
Getter 方法用于从类对象中检索属性的值,而 Setter 方法用于设置类对象中属性的值。
但是,出于命名约定的目的,这些 Getter 和 Setter 方法的命名通常就好像它们本身就是属性一样。例如,名为 “getUser” 的方法实际上可能是一个检索用户信息的 Getter 方法,而名为 “storeData” 或 “setUser” 的方法实际上可能是一个 Setter 方法。
ymdt
代表 yearMonthDateTime
,我们应该写成dateWithTimeZone
。userList = { u1:a, u2:b, ... }
这不是一个 List
,而是一个 Object
。因此名称应该是 userObj
或 userMap
。allAccounts = accounts.filter()
,过滤后我们得到的是经过筛选的数据,而不是所有数据。所以这里的名称应该是 filteredAccounts
。有时我们会遇到这样的情况,我们得到相似的数据并且我们必须给出相似的名称。在那种情况下,我们通常会更具体地指定变量并相应地命名它们。
例如;在 Analytics (谷歌网站统计信息站点) 中,我们可以获得与每日报告和数据相关的各种信息。
Don’t Name | Do Name |
---|---|
analytics.getDailyData(day) | analytics.getDailyReport(day) |
analytics.getDayData(day) | analytics.getDataForToday(day) |
analytics.getRawDailyData(day) | analytics.getParsedDailyData(day) |
analytics.getTodayData(day) | analytics.getDailyInfo(day) |
凭借其鲜明的特征,我们可以很容易地识别它们,同时调试和修正也变得容易。
因为编码不是一天的任务。它就像一场马拉松,需要一致性和没有 bug 的方法。因此,我们必须与命名约定、命名变量的样式、函数/方法和类保持一致。如果我们使用了 camelCase
约定来命名,那么我们应该坚持使用它。
许多开源项目都提供了与命名约定和样式相关的指南。在 PR 之前,我们应该通过这些指南来维护项目代码的一致性。
一致性类型 | 在之后的整个代码中 |
---|---|
name-casing | if used camelCase |
if used getUser() | 不要使用 fetchUser() 或者 retriveUser() ,坚持使用 get() |
最后,正如我们已经谈了很多命名,我们也可以说,通过正确的命名和代码结构,我们将我们的表达留在代码上,这会激发其他编码人员和程序员这样编码/编写。您未来的员工和同事可以在修复错误的同时轻松理解您的代码,并欣赏您的工作,因为他们不会在理解您的代码时摸不着头脑。
最后,正如我们已经谈了很多命名,我们也可以说,通过适当的命名和代码结构,我们将我们的表达留在代码上,这会激发其程序员这样 编码/编写。您未来的员工和同事可以在修复错误的同时轻松理解你的代码,并欣赏你的工作,因为他们不会在理解你的代码时摸不着头脑。
“阅读这本书有两种原因:第一,你是个程序员;第二,你想成为更好的程序员。很好,IT行业需要更好的程序员!”——罗伯特·C. 马丁(Robert C. Martin)
冲浪时看到的一篇文章,觉得写的很不错。翻译下放到自己博客上,顺便学习一下这本书的思想😁
以下是《代码整洁之道》中文翻译版的一个在线仓库,可以直接看
作为开发人员,我们的工作主要是编写代码💻、review、重构和 writing again。大多数情况下,当我们开始一个新项目时,我们往往会在脑海中构思其特性、布局、功能以及实现方法。随后,我们会详细设计并开始编写代码来实现这些想法。由于每个项目都需要反复修改和完善,我们必须不断检查代码,并根据需求和情况进行调整。但是,如果我们花费大量时间寻找正确的位置来插入新代码或进行修改,那么可能是因为我们对代码不够熟悉,或者代码编写不正确。
如果编写的代码需要花费很长时间才能展示出其功能和特性,那么这些代码通常不是干净的代码🤢🤮。这些代码可能没有得到正确的缩进格式,变量、函数、方法、类的命名也可能不够规范,导致代码难以理解和维护。因此,我们应该编写易于阅读和理解的代码,使用清晰的变量、函数、方法和类名称,并保证正确的缩进格式,以便使代码更加易于维护和修改。
另外,干净的代码指的是如何编写代码,而不是如何放置或维护代码。这是由代码架构来处理整个项目及其功能。干净的代码只涉及编写易于阅读的代码,它最终有助于代码的维护。它专注于解决手头的单一问题。因此,编写干净的代码,需要着重保证代码易于阅读、理解和维护,以便让代码更加具备可读性、可维护性和可扩展性。
代码应该易于阅读、易于理解和易于维护🥱。干净的代码通常具备以下特点:
最后,代码应该像一个故事,你是它的作者😍。
大多数程序员在遵循干净的代码规范时容易出现以下问题:
然而,干净的代码并不需要强类型分配,因为这些对于了解我们使用的变量类型来说是微不足道的事情。有些语言在声明变量之前需要进行强类型化,如 Java。在某些地方,我们可以注释输出-响应类型。我们还可以通过为它们指定适当的名称将这些类型存储在变量中,例如,如果它是一个布尔值,我们可以为它命名为 isShow、response🆗等。好的,我们以后再深入讨论这个问题,现在我们主要关注干净的代码。
由于代码不是死的,而是一直在运行中,因此没有开发人员可以编写最优和最干净的代码。我们必须维护它,并根据需求不断进行更改。已经多次阅读代码的资深开发者应该或必须知道如何使这些代码变得干净。
编写干净的代码是一种艺术💃,任何人都可以通过实践💪和信念掌握它。通过保持代码格式的一致性和编写干净的代码,这将成为你的创造性工作,并且会一直伴随着你。初学者们会追随你的脚步,而你的同事们会赞赏👏你编写干净代码的技巧。
由于实习生👦在编写代码时没有遵循干净代码的良好实践,因此应由资深开发者👨💼对他们进行指导。如果资深开发者不知道这些干净代码的良好实践,那么他们应该开始通过阅读相关博客来接受这些实践。😎
编程不是一次性的操作,而更像是反复进行的网球回击⛹🏻♂️。因此,如果你想打出一个成功的回击,就必须确保它是最佳的回击🏌🏻♀️。为此,你必须使代码易于阅读、易于维护和易于理解。
干净的代码实践需要时间,但一旦在编程生涯中接受了这种实践,它将成为未来的助手。你将不会被难以辨认和无法理解的代码🥶困扰,在修复错误或甚至破碎的生产代码时,那时候比开发过程中花费的时间更加关键。
因此,我们应该专注于编写干净的代码,并通过实践,可以在规定时间内快速交付项目。干净代码与快速代码之间的重要性也源自以下图表。
“Dry Code” 是 “Don’t Repeat Yourself” 的缩写,意思是 “不要重复自己”,它表示在编写代码时尽量避免冗余,将逻辑和功能分离,使代码更加简洁、可读性更高、易于维护。
“Dry Code” 主要关注代码是否能够实现功能,而 “Clean Code” 则注重代码的可读性和可维护性。编写易于维护且更易于修改的代码有助于提高生产效率并使代码更具弹性。
这张图展示了通过采用 ”Dry Code” 方式编写代码并且只是为了快速交付生产环境,最终会因为高昂的维护成本在未来消亡😥(换句话说,如果编写代码时只考虑能否运行,而不考虑代码的可读性和可维护性,最终代码将难以维护并且很可能被淘汰)。相比之下,易于维护且易于交付的 “Clean Code” 将是更具生产力和开放性变化的选择。如果我们将生产力作为X轴,新的图表📈📉也显示出了 “Clean Code” 和 “Quick Code” 的同样行为。
“Clean Code” 并不主张延迟项目交付,而是鼓励编写易于维护且更易于修改的代码🧞♂️。当你给上级汇报时,不要说你正在编写一份需要很长时间⌛才能完成的 “Clean Code”,而是应该保证代码整洁并及时交付,这会让上级感到满意💋。那些在创作过程中令人愉悦的和具有吸引力的事物并不需要太长时间。当你像讲故事一样编写代码时,你就可以按时交付了。只要提高你的叙述能力即可。
乡秀树
你死过一次
我把我的生命赋予了你
你已经是奥特曼了
我是奥特曼
这是你的秘密
我们将共同为捍卫人类的自由和幸福奋斗
我是奥特曼
我的使命是打击一切
威胁人类自由和幸福的敌人
队长 乡君他首次作战
不是故意的 谁都会有过失的
他不是由于过失
这是骄傲自大
我的确自满了
我一直把自己看作是奥特曼
在这之前 我是乡秀树
我应该努力才对
我问你
你在 MAT 队尽力了么
一个运动员在比赛的时候
他应该想到赢
即使落后
也必须相信最后的胜利一定属于自己
我问你
你有没有因失败而气馁
即使失控
你也要以胜利为目标
你
一定要做那样的人
乡哥哥去哪里
回故乡
本约定不是固执己见的,均收集于社区主流规范和 vue 官方风格指南。
更细粒度风格指南请看:这里是官方的 Vue 特有代码的风格指南
虽然学习和遵守规范的过程可能有些痛苦,但我坚信它会对你的开发生涯带来巨大的好处。请不要排斥规范,因为它能够让你编写出更优雅的代码,给你带来身心愉悦。合理的规范有助于团队之间更好地沟通协作,提高开发效率,并且能够早期发现潜在的 BUG 和错误。
现在前端的工作与以前的前端开发已经完全不同了。
刚接触前端的时候,做一个页面,是先创建 HTML 页面文件写页面结构,再在里面写 CSS 代码美化页面,再根据需要写一些 JavaScript 代码增加交互功能,需要几个页面就创建几个页面,相信大家的前端起步都是从这个模式开始的。
而实际上的前端开发工作,早已进入了前端工程化开发的时代,已经充满了各种现代化框架、预处理器、代码编译…
最终的产物也不再单纯是多个 HTML 页面,经常能看到 MPA / SPA / SSG / CSR / SSR 等词汇的身影。
名词 | 全称 | 中文 |
---|---|---|
MPA | Multi-Page Application | 多页面应用(传统开发) |
SPA | Single-Page Application | 单页面应用(现在) |
SSG | Static-Site Generation | 静态页面生成(Hexo / VuePress) |
名词 | 全称 | 中文 |
---|---|---|
CSR | Client-Side Rendering | 客户端渲染(Js / Vue / React) |
SSR | Server-Side Rendering | 服务端渲染(Nuxt.js) |
如果一个 HTML 文件中引入两个 JS 文件,两个文件中都声明了一个重名的变量,则第二个 JS 文件中的变量则会覆盖第一个变量,后续有业务依赖这个变量则会导致代码逻辑出错。
原因是 JavaScript 的加载顺序是从上到下,当使用 var
声明变量时,如果命名有重复,那么后加载的变量会覆盖掉先加载的变量。
这是使用 var
声明的情况,它允许使用相同的名称来重复声明,那么换成 let
或者 const
呢?
虽然不会出现重复声明的情况,但同样会收到一段报错:
1 | Uncaught SyntaxError: Identifier 'xxx' has already been declared (at lib-2.js:1:1) |
这次程序直接崩溃了,因为 let
和 const
无法重复声明,从而抛出这个错误,程序依然无法正确运行。
以上只是一个最简单的案例,就暴露出了传统开发很大的弊端,然而并不止于此,实际上,存在了诸如以下这些的问题:
script
的加载从上到下,容易阻塞页面渲染当然,实际上还会有更多的问题会遇到。
为了解决传统开发的弊端,前端也开始引入工程化开发的概念,借助工具来解决人工层面的烦琐事情。
在传统开发的弊端里,主要列举的是开发层面的问题,工程化首要解决的当然也是在开发层面遇到的问题。
在开发层面,前端工程化有以下这些好处:
还有非常多的体验提升,列举不完。而对应的工具,根据用途也会有非常多的选择,在后面的学习过程中,会一步一步体验到工程化带来的好处。
除了对开发者有更好的开发体验和效率提升,对于团队协作,前端工程化也带来了更多的便利,例如下面这些场景:
以前的项目结构比较看写代码的人的喜好,虽然一般在研发部门里都有 “团队规范” 这种东西,但靠自觉性去配合的事情,还是比较难做到统一,特别是项目很赶的时候。
工程化后的项目结构非常清晰和统一,以 Vue 项目来说,通过脚手架创建一个新项目之后,它除了提供能直接运行 Hello World 的基础代码之外,还具备了如下的统一目录结构:
src
是源码目录src/main.ts
是入口文件src/views
是路由组件目录src/components
是子组件目录src/router
是路由目录一个完整的 Vue3 + TypeScript + Vite 项目的目录结构大致如下:
1 | . |
不管是接手其他人的代码或者是修改自己不同时期的代码,可能都会遇到这样的情况,例如一个模板语句,上面包含了很多属性,有的人喜欢写成一行,属性多了维护起来很麻烦,需要花费较多时间辨认:
1 | <template> |
而工程化配合统一的代码格式化规范,可以让不同人维护的代码,最终提交到 Git 上的时候,风格都保持一致,并且类似这种很多属性的地方,都会自动帮格式化为一个属性一行,维护起来就很方便:
1 | <template> |
同样的,写 JavaScript 时也会有诸如字符串用双引号还是单引号,缩进是 Tab 还是空格,如果用空格到底是要 4 个空格还是 2 个空格等一堆 “没有什么实际意义” 、但是不统一的话协作起来又很难受的问题……
在工程化项目这些问题都可以交给程序去处理,或者是在提交代码的时候配合 Git Hooks 自动格式化,都可以做到统一风格。
传统的开发模式里,只能够写 JavaScript ,而在工程项目里,可以在开发环境编写带有类型系统的 TypeScript ,然后再编译为浏览器能认识的 JavaScript 。
在开发过程中,编译器会检查代码是否有问题,比如在 TypeScript 里声明了一个布尔值的变量,然后不小心将它赋值为数值:
1 | // 声明一个布尔值变量 |
编译器检测到这个行为的时候就会抛出错误:
1 | # ... |
它的报错是在编译阶段发生的,而不是在线上阶段,从而得以及时发现问题并修复,减少线上事故的发生。
个人认为目前代码规范分为硬性和软性。
所谓硬性规范,是可以靠工具强制保证效果的,比如缩进几个字符、每行多少字母、句尾加分号等,可以用 Lint 工具保证落地效果的规范;
所谓软性规范,是指诸如方法命名、文件命名、套路代码等无法用工具强行约束的规则。这种规则只能算是一种约定,更多的是要靠自觉,除了 Code Review(代码评审) 之外,暂时也想不到其他有效的方法来保证其落地效果。
现在我们需要从开发环境到代码书写规范制定一套有利于团队开发效率的约定
VPN 应该是每一个开发人员必备,毕竟很多技术都是从外面过来的,上 Github、Google、甚至一些文档都需要。
tips:
搜索不要用百度!不要用百度!不要用百度!你一定会搜到一堆你不想要的答案。Google 配合英文关键词搜索可以找到大部分问题答案,实在不行还有 gtp、copilot
在前端工程化开发过程中,已经离不开各种命令行操作,例如:管理项目依赖、本地服务启动、打包构建,还有拉取代码 / 提交代码这些 Git 操作等等。
在 Windows 平台,可以使用自带的 CMD 或者 Windows PowerShell 工具。
但为了更好的开发体验,推荐使用以下工具(需要下载安装),可以根据自己的喜好选择其一:
注:从Windows 11 22H2 版本开始,Windows Terminal 将正式成为 Windows 11 的默认设置
名称 | 简介 | 下载 |
---|---|---|
Windows Terminal | 由微软推出的强大且高效的 Windows 终端 | 前往 GitHub 下载 |
CMDer | 一款体验非常好的 Windows 控制台模拟器 | 前往 GitHub 下载 |
如果使用的是 Mac 系统,可以直接使用系统自带的 “终端” 工具进行开发。
TIP
其实只要能正常使用命令行,对于前端工程师来说就可以满足日常需求,但选择更喜欢的工具,可以让自己的开发过程更为身心愉悦!
这里使用 NVM 来管理 Node 版本
顾名思义,NVM 是一种用于管理设备上的 Node 版本的工具。
你设备上的不同项目可能使用不同版本的 Node。对这些不同的项目仅使用一个版本(由 npm
安装的版本)可能无法为你提供准确的执行结果。
例如,如果你将 10.0.0 的 Node 版本用于使用 12.0.0 的项目,则可能会出现一些错误。如果你用 npm 将 Node 版本更新到 12.0.0,并且你将它用于使用 10.0.0 的项目,你可能无法获得预期的体验。
事实上,你很可能会收到一条警告:
1 | This project requires Node version X |
无需使用 npm 为你的不同项目安装和卸载 Node 版本,你可以使用 NVM,它可以帮助你有效地管理每个项目的 Node 版本。
NVM 允许你安装不同版本的 Node,并根据你正在通过命令行处理的项目在这些版本之间切换。
NVM 主要在 Linux 和 Mac 上得到支持。它不支持 Windows。但是 coreybutler 创建了一个类似的工具,用于在 Windows 中提供 NVM 体验,叫作 nvm-windows。
在 nvm-windows 仓库的 Readme 文件中,单击 “Download Now!”:
这将打开一个显示不同 NVM 版本的页面。
安装最新版本的 nvm-setup.exe
文件
打开你下载的文件,然后完成安装向导(会自动配置环境变量)
完成后,你可以通过运行以下命令确认 NVM 已安装:
1 | nvm -v |
这应该会显示你所安装的 NVM 版本
安装了 NVM 后,你现在可以在你的 Windows、Linux 或 Mac 设备中安装、卸载和切换不同的 Node 版本。
你可以像这样安装 最新 Node 版本
1 | nvm install latest |
你也可以安装指定版本
1 | nvm install vX.Y.Z |
你还可以通过运行以下命令将版本设置为默认版本:
1 | nvm alias default vX.Y.Z |
如果你想在任何时候使用特定版本,你可以在终端中运行以下命令:
1 | nvm use vA.B.C |
NVM 使管理需要不同 Node.js 版本的不同项目变得更加容易。
包管理器( Package Manager )是用来管理依赖包的工具,比如:发布、安装、更新、卸载等等。
Node 默认提供了一个包管理器 npm
,在安装 Node.js 的时候,默认会一起安装 npm 包管理器,可以通过以下命令查看它是否正常。
1 | npm -v |
如果正常,将会输出相应的版本号。
其他的包管理器 还有 yarn、pnpm,使用方式都大同小异,本约定使用 pnpm 来作为团队协作包管理器。理由可以看官方文档,它们之间的渊源也可以自行搜索,这里不再赘述。
通过 npm 安装 pnpm
1 | npm install -g pnpm |
npm 命令 | pnpm 等价命令 |
---|---|
npm install | pnpm install |
npm i <pkg> | pnpm add |
npm run <cmd> | pnpm |
其他命令可以查阅官方文档
本约定统一使用 VSCode 作为前端代码编辑器,点击下载:Visual Studio Code
好的插件能让我们的开发事半功倍
VSCode 安装后默认是英文本,需要自己进行汉化配置, VSCode 的特色就是插件化处理各种功能,语言方面也一样。
点击下载:Chinese (Simplified)
Vue 官方推荐的 VSCode 扩展,用以代替 Vue 2 时代的 Vetur ,提供了 Vue 3 的语言支持、 TypeScript 支持、基于 vue-tsc 的类型检查等功能。
点击下载:Volar
TIP
Volar 取代了 Vetur 作为 Vue 3 的官方扩展,如果之前已经安装了 Vetur ,请确保在 Vue 3 的项目中禁用它。
可以快速完成 HTML 标签的闭合,除非通过 .jsx
/ .tsx
文件编写 Vue 组件,否则在 .vue
文件里写 template
的时候肯定用得上。
点击下载:Auto Close Tag
假如要把 div
修改为 section
,不需要再把 <div>
然后找到代码尾部的 </div>
才能修改,只需要选中前面或后面的半个标签直接修改,插件会自动把闭合部分也同步修改,对于篇幅比较长的代码调整非常有帮助。
点击下载:Auto Rename Tag
快速打印 console.log()
,选中变量后按下快捷键,会在代码下方插入一行带颜色的 console
语句
快捷方式:
cmd
+ shift
+ l
ctrl
+ l
点击下载:console helper
高亮显示 .ENV
配置文件内容
点击下载:DotENV
更好的报错提示,将报错信息显示在当前报错位置
点击下载:Error Lens
增强代码缩进提示
点击下载:indent-rainbow
ES6 语法的一些快捷代码片段,提高开发效率
点击下载:JavaScript (ES6) code snippets
路径提示插件,引入组件的时候会有提示功能
点击下载:JavaScript (ES6) code snippets
Windi CSS IntelliSense 通过为 VSCode 用户提供自动完成、语法突出显示、代码折叠和构建等高级功能来增强 Windi 开发体验。
删除页面多余的空格
一款 antfu 开发的 VSCode 主题,主题自己定义,但最好不用亮色主题,因为太亮会吸引虫子(bug)。
项目目录图标主题
这是 ESLint 在 VSCode 的一个扩展, TypeScript 项目基本都开了 ESLint ,编辑器也建议安装该扩展支持以便获得更好的代码提示。
点击下载:VSCode ESLint
点击访问:ESLint 官网 了解更多配置。
Stylelint 的官方 VSCode 扩展,
点击下载:Stylelint
一个可以让编辑器遵守协作规范的插件,详见 项目代码风格统一神器 。
在项目根目录下再增加一个名为 .editorconfig
的文件。
这个文件的作用是强制编辑器以该配置来进行编码,比如缩进统一为空格而不是 Tab ,每次缩进都是 2 个空格而不是 4 个等等。
1 | # 官网是这么介绍 EditorConfig 的: |
使用 VS Code 的文件嵌套功能,使文件树更清晰的配置。
配置:
默认情况下,它将每 12 小时检查一次更新。您也可以通过执行 command 手动完成File Nesting Updater: Update config now
。
1 | // setting.json |
点击下载:File Nesting Updater
Vue Devtools 是一个浏览器扩展,支持 Chrome 、 Firefox 等浏览器,需要先安装才能使用。
点击安装:Vue Devtools 的浏览器扩展
当在 Vue 项目通过 npm run dev
等命令启动开发环境服务后,访问本地页面(如: http://localhost:3000/
),在页面上按 F12 唤起浏览器的控制台,会发现多了一个名为 vue
的面板。
面板的顶部有一个菜单可以切换不同的选项卡,菜单数量会根据不同项目有所不同,例如没有安装 Pinia 则不会出现 Pinia 选项卡,这里以其中一部分选项卡作为举例。
Components 是以结构化的方式显示组件的调试信息,可以查看组件的父子关系,并检查组件的各种内部状态:
输入以下命令创建一个基于 vite 的 vue3 脚手架
1 | pnpm create vite |
使用 antfu 大佬的预设
antfu:Vue、Vite、Nuxt 核心团队成员。VueUse、Slidev、Vitest、UnoCSS 作者
本约定使用 ESLint 和 StyleLint 来作为代码格式检查工具。为什么没有 Prettier 可以看看 antfu 的一篇文章:
我的观点如下:
- 只单纯使用 Prettier 十分合理 - 开箱即用是个很棒的功能
- 如果你需要使用 ESLint,它也可以像 Prettier 一样格式化代码 - 而且更加可配置
- Prettier + ESLint 仍然需要大量的配置 - 它并没有让你的生活变得更简单
- 你可以在 ESLint 中完全控制代码风格,但在 Prettier 中却无法做到,这两者混合在一起感觉很奇怪
- 我不认为 Parse 两次代码会更快
基于 @antfu/eslint-config 的预设配置,减少大量复杂配置
从结果来看,使用 ESLint 其实也可以非常简单:
1 | pnpm add -D eslint @antfu/eslint-config |
1 | // .eslintrc |
你通常不需要.eslintignore
,因为它已由预设提供。
这样就可以了。配合 IDE 扩展,还可以在保存时触发自动修复。
1 | // settings.json |
更多规则可以看 styleLint 文档
安装相关工具包
注:这里限制下 stylelint 版本要在 14版本内,高本版删除了大量规则导致检查无效
1 | pnpm add -D stylelint@14.16.1 stylelint-config-standard@29.0.0 stylelint-config-html@1.1.0 stylelint-order@5.0.0 stylelint-config-recess-order@3.1.0 stylelint-less@1.0.6 postcss@8.4.21 postcss-html@1.5.0 postcss-less@6.0.0 |
<style>
类 vue、html 文件标签中的样式<style lang="less">
下的 less 样式1 | // package.json |
统一规范团队包管理器
首先我们来提一个团队开发中很常见的需求:一般来说每个团队都会统一规定项目内只使用同一个包管理器,譬如 npm、yarn、pnpm 等,如果成员使用了不同的包管理器,则可能会因为 lock file 失效而导致项目无法正常运行,虽然这种情况一般都可以通过项目的上手文档来形容共识,但有没有更好的解决方案,比如在项目安装依赖时检测如果使用了不同的包管理器就抛出错误信息?
当然是可以的,pnpm 就有一个包叫做 only-allow ,连 vite 都在使用它
1 | pnpm install only-allow -D |
在 package.json
中添加如下命令
1 | // package.json |
测试:
此时触发 preinstall
钩子他会打断安装,强制让你使用 pnpm 包管理器
经过验证,貌似有个 npm 本身的 bug,就是 preinstall 的执行是在 install 之后,这样提示我换用其他 pm 之前,其实已经安装完成了
npm 官方已经接收到这个 issue 了。目前还没有好的解决办法,或者使用 npm 版本 < 7 的(这个问题不大)
这是一个 git hook 的工具,可以通过在 git 操作期间的一些钩子,做一些额外的操作,比如执行 lint test 等。点击查看所有 git hooks 点击查看 husky
文档
一般情况可能用到以下钩子:
pre-commit
:代码提交之前触发,可以通过此钩子判断代码是否符合规范。commit-msg
:对 commit 的信息校验,可以通过此钩子判定 commit 是否合法。pre-push
: 代码提交之前触发,可以通过此钩子对业务代码执行一些测试。经过上述操作后,从代码编辑阶段解决了代码格式化的问题。但是总有一些小伙伴写出不符合规则的代码,并且提交到 git 仓库。
这个时候可以通过 git hook 来拦截提交的代码并执行之前配置的格式化代码相关命令,让提交的代码都是符合规范的。
详细教程请看这篇文章:
前端工程化配置(上) 构建代码检查工作流:husky + lint-staged 配置
Husky 的作用就是在执行 git commit
的时候,按照配置自动修复暂存区的文件代码格式。
注:类型问题不会自动修复,需要手动
commitlint
用来规范团队成员的提交格式信息。团队中规范了 commit 可以更清晰的查看每一次代码提交记录
全局安装这个包:
1 | npm install -g commitizen |
详细教程请看这篇文章:
前端工程化配置(下) 规范仓库提交记录 commitlint + commitizen + cz-git + 配置
feat
增加新功能fix
修复问题/BUGstyle
代码风格相关无影响运行结果的perf
优化/性能提升refactor
重构revert
撤销修改test
测试相关docs
文档/注释chore
依赖更新/脚手架配置修改等workflow
工作流改进ci
持续集成types
类型定义文件更改wip
开发中这些规则有助于防止错误,因此请不惜一切代价学习并遵守这些规则。例外情况可能存在,但应该非常罕见,并且只有那些同时具有 JavaScript 和 Vue 专业知识的人才能做到。
SFC
或 TSX
形式编写;SFC
中的标签顺序统一为 <script>
、<template>
、<style>
,这样更符合“先声明,后使用”的逻辑,典型案例如下:1 | <!-- Bad. What is 'routes'? --> |
凡是跟组件名相关的(组件名、文件名及在模板中使用),都必须采用大驼峰 PascalCase
形式
拿一个 Login 组件举例
应用特定于应用程序的样式和约定的基本组件(又名表示组件、哑组件或纯组件)都应以特定前缀开头,例如Base
、App
或V
。
1 | <!-- Bad --> |
1 | <!-- Good --> |
在编写全局组件的时候目录结构应有一个统一对外暴露的接口
1 | src/components/ |
这样外部调用只需
1 | import { CountTo } from '@/components/CountTo' |
1 | <CountTo :start-val="1" :end-val="100" class="text-3xl"/> |
如果是大型全局组件应按照以下目录划分
1 | src/components/ |
用户组件名称应始终为多词,根组件除外。这可以防止与现有和未来的 HTML 元素发生冲突,因为所有 HTML 元素都是一个单词。
1 | <!-- Bad --> |
1 | <!-- Good --> |
与父组件紧密耦合的子组件,命名要以父组件名为前缀,例:TodoList
、TodoListItem
、TodoListItemButton
,详见 紧密耦合的组件名称;
如果组件仅在单个父组件的上下文中有意义,则该关系应该在其名称中显而易见。由于编辑器通常按字母顺序组织文件,这也使这些相关文件彼此相邻。
1 | <!-- Bad --> |
1 | <!-- Good --> |
一般化描述放前,特殊化描述放后,例:SearchButtonClear
、SettingsCheckboxLaunchOnStartup
,详见 组件名称中的单词顺序
组件名称应以最高级别(通常是最通用的)词开头,并以描述性修饰词结尾。
1 | <!-- Bad --> |
1 | <!-- Good --> |
没有内容的组件在单文件组件、字符串模板和 JSX 中应该是自关闭的——但绝不是在 DOM 模板中。
自关闭的组件表明它们不仅没有内容,而且本来就没有内容。这是一本书中的空白页和标有“此页有意留白”的页之间的区别。没有不必要的结束标记,您的代码也更清晰。
注意:
该特性只能在 Vue 的 template
和 JSX/TSX
中实现,因为 HTML 不允许自定义元素是自关闭的——只有官方的“void”元素才可以
1 | <!-- Bad --> |
1 | <!-- Good --> |
组件名称应该更喜欢完整的单词而不是缩写。
编辑器中的自动完成功能使编写较长名称的成本非常低,而它们提供的清晰度是无价的。特别是,应始终避免使用不常见的缩写。
1 | <!-- Bad --> |
1 | <!-- Good --> |
具有多个属性的元素应该跨越多行,每行一个属性。
在 JavaScript 中,将具有多个属性的对象分成多行被广泛认为是一个很好的约定,因为它更容易阅读。我们的模板和JSX值得同样的考虑。
1 | <!-- Bad --> |
1 | <!-- Good --> |
组件模板应该只包含简单的表达式,将更复杂的表达式重构为计算属性或方法。
模板中的复杂表达式会降低它们的声明性。我们应该努力描述应该出现什么,而不是我们如何计算该值。计算属性和方法还允许重用代码。
1 | // Bad |
1 | <!-- Good --> |
1 | // The complex expression has been moved to a computed property |
复杂的计算属性应该拆分成多个简单的计算属性
1 | // Bad |
1 | // Good |
在提交的代码中,props 定义应该尽可能详细,至少指定类型。
1 | // Bad |
1 | // Good |
1 | // Even better! |
defineProps
、defineEmits
要用类型声明的方式,而非运行时声明(混着使用会导致编译器报错)。
运行时声明的方式只能设置参数的类型、默认值、是否必传、自定义验证。报错信息为控制台 warn 警告
若想设置 [ 编辑器报错、编辑器语法提示 ] 则需要使用类型声明的方式。使用类型声明的时候,静态分析会自动生成等效的运行时声明,从而在避免双重声明的前提下确保正确的运行时行为。
针对类型的 defineProps
声明的不足之处在于,它没有可以给 props 提供默认值的方式。为了解决这个问题,Vue 还提供了 withDefaults
编译器宏:
<script setup>
语法糖中才能使用的编译器宏。他不需要导入且会随着 <script setup>
处理过程一同被编译掉。1 | export interface Props { |
上面代码会被编译为等价的运行时 props 的 default
选项。此外,withDefaults
辅助函数提供了对默认值的类型检查,并确保返回的 props
的类型删除了已声明默认值的属性的可选标志。
另外 props 在声明时使用 camelCase
,但在模板中使用时使用 kebab-case
,详见 Props 文档
1 | defineProps({ |
1 | <span>{{ greetingMessage }}</span> |
虽然理论上你也可以在向子组件传递 props 时使用 camelCase 形式 (使用 DOM 模板时例外),但实际上为了和 HTML attribute 对齐,我们通常会将其写为 kebab-case 形式:
1 | <MyComponent greeting-message="hello" /> |
1 | const emit = defineEmits<{ |
v-for 要遍历的数组对象中最好提供唯一 id 值来作为 v-for 的 key 而不是用下标来替代 key。
最好始终添加一个唯一的密钥,这样你和你的团队就永远不必担心这些边缘情况。然后在不需要对象恒定性的罕见的性能关键场景中,你可以有意识地进行异常处理。
1 | <!-- Good --> |
v-if
与 v-for
v-if
切勿在与相同的元素上使用 v-for
1 | <!-- Bad --> |
1 | <!-- Good --> |
1 | <!-- Even better! --> |
始终使用指令缩写,即:
、@
、#
,除了 v-bind="obj"
这种情况。
:
for v-bind:
@
for v-on:
#
for v-slot
1 | <!-- Bad --> |
1 | <!-- Good --> |
使用临时变量时请结合实际需要进行变量命名
有些喜欢取 temp
和 obj
之类的变量,如果这种临时变量在两行代码内就用完了,接下来的代码就不会再用了,还是可以接受的,如交换数组的两个元素。但是有些人取了个 temp
,接下来十几行代码都用到了这个 temp
,这个就让人很困惑了。所以应该尽量少用 temp
类的变量
1 | // Bad |
不要使用中文拼音,如 shijianchuo
应改成 timestamp
; 如果是复数的话加 s
,或者加上 List
,如 orderList
、menuItems
; 而过去式的加上 ed
,如 updated / found
等; 如果正在进行的加上 ing
,如 calling
。
【强制】变量不要先使用后声明
【强制】不要声明了变量却不使用
【强制】不要在同个作用域下声明同名变量
【强制】为了快速知晓变量类型,声明变量时要赋值
1 | // Bad |
===
代替 ==
,!==
代替 !=
(==
会自动进行类型转换,可能会出现奇怪的结果)let
定义变量,const
定义常量up / down
、begin / end
、opened / closed
、visible / invisible
、scource / target
不好的变量名 | 好的变量名 |
---|---|
inp | input, priceInput |
day1, day2, param1 | today, tomorrow |
id | userId, orderId |
obj | orderData, houseInfos |
tId | removeMsgTimerId |
handler | submitHandler, searchHandler |
kebab-case
格式1 | @mainFontColor: #444; |
【强制】不出现空的规则(声明块中没有声明语句)
【强制】不要设置太大的 z-index(一个正常的系统的层级关系在 10 以内就能完成)
【推荐】选择器不要超过4层(在 Less 中避免嵌套超过 4 层)
【推荐】用 border: 0;
代替 border: none;
【强制】永远不要写内联样式!!永远不要写内联样式!!永远不要写内联样式!!这会给你的同事带来困扰
1 | <text style="font-size: 15px;color: #fff;position: absolute;">Back in the USSR</text> |
虽然学习和适应规范有一定的成本,但是统一的规范可以很大程度降低团队的沟通、维护成本。而且越熟悉业内主流的规范,对于阅读开源项目来说越有利。同样的,自己写的项目满足主流规范,也更容易被他人接受。总之,平时开发时,对于规范的遵守越严格,对未来的好处就越大,当然习惯的成本也会比较大,但这是值得的。
]]>我们都知道 JavaScript 是一门单线程语言,也就是说,同一个时间内只能做一件事。至于它为什么不能是多线程,这和它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个 DOM 节点,此时浏览器则会无法处理而报错。
所以为了避免复杂性,从一诞生,JavaScript 就是单线程,这是这门语言的核心特征,将来也不会改变。
单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
如果排队是因为计算量大,CPU 忙不过来,倒也算了,但是很多时候 CPU 是闲着的,因为 IO 设备(输入输出设备)很慢(比如 Ajax 操作从网络读取数据),不得不等着结果出来,再往下执行。
JavaScript 语言的设计者意识到,这时主线程完全可以不管 IO 设备,挂起处于等待中的任务,先运行排在后面的任务。等到 IO 设备返回了结果,再回过头,把挂起的任务继续执行下去。
于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。
同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入”任务队列”(task queue)的任务,只有”任务队列”通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
(2)主线程之外,还存在一个”任务队列”(task queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。
(3)一旦”执行栈”中的所有同步任务执行完毕,系统就会读取”任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
(4)主线程不断重复上面的第三步。
“任务队列”是一个事件的队列(也可以理解成消息的队列),IO 设备完成一项任务,就在”任务队列”中添加一个事件,表示相关的异步任务可以进入”执行栈”了。主线程读取”任务队列”,就是读取里面有哪些事件。
“任务队列”中的事件,除了 IO 设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入”任务队列”,等待主线程读取。
所谓”回调函数”(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。
“任务队列”是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,”任务队列”上第一位的事件就自动进入主线程。
事件循环分为三个部分:主线程、宏任务队列、微任务队列,异步任务都会被丢到宏/微任务中
异步任务分为宏任务和微任务
宏任务:script、setTimeout、setInterval、setImmeditate、I/O
微任务:process.nextTick(nodejs)、promise.then(cb)、object.observe
关于 promise
有一个很容易出错的点,只有 promise.then()
中的回调函数才会被放入到微任务当中去,而 promise
这个构造函数的参数是作为同步代码执行的
1 | new promise(resolve => { |
同样的在 async
函数中 await
右边的代码也是同步代码
1 | async function async1() { |
简单来说,执行一段代码时,整段代 码会作为宏任务进入主线程执行,接下来会有 3 种情况:
setTimeout
,分发到宏任务队列promise.then(cb)
,分发到微任务队列下面看这个示例:
1 | console.log(1); // 直接执行 |
在一次事件循环中,会只执行一个宏任务和微任务队列中的所有微任务
都执行完后则进入下一轮事件循环,再从宏任务开始执行(setTimeout)。
所以在整段代码中,setTimeout
是在 then
之后执行的,因为它俩不在同一次事件循环中。
这里很多人会混淆一个问题,我也是纠结了好久才整明白
问题:JavaScript 事件循环到底是先执行宏任务还是先执行微任务?
问题的关键是在于你指的是一次事件循环中还是一整段代码中。
在一次事件循环中,宏任务先执行
整段代码中,异步的微任务先执行
其实整个 <script></script>
代码块它是一个宏任务,我们可以理解成事件循环就是起始于这个宏任务,那这段代码在执行过程中就会向微任务队列和宏任务队列推入各种任务
同步任务会直接进入主线程依次执行
异步任务会再分为宏任务和微任务
宏任务进入到 Event Table
中,并在里面注册回调函数,每当指定的事件完成时,Event Table
会将这个函数移到 Event Queue
中
微任务也会进入到另一个 Event Table
中,并在里面注册回调函数,每当指定的事件完成时,Event Table
会将这个函数移到 Event Queue
中
当主线程内的任务执行完毕,主线程为空时,会检查微任务的 Event Queue
,如果有任务,就全部执行,如果没有就执行下一个宏任务
上述过程会不断重复,这就是 Event Loop
,比较完整的事件循环。
那么为什么会有微任务呢?这种设计是为了给紧急任务一个插队
的机会,否则新入队的任务永远被放在队尾
。具体表现在执行过程:
当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。
如果在执行微任务的过程中,产生了新的微任务
,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束
。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行
。
下面这个示例能很好的解答什么是事件循环,还挺绕,但是多看几遍其实也就那回事
1 | console.log('script start'); // 同步 |
第一轮事件循环:
首先是同步代码 console.log('script start');
立即打印的 script start
接着执行 async1()
函数,在 async1()
中又会先执行 async2()
,在 async
函数中 await
右边的代码是同步代码,最终执行 async2()
,打印 async2 end
执行完 async2()
之后,await
后面的代码会被放入到微任务队列中,就是这段 console.log('async1 end');
下面遇到了两个 setTimeout
先不管,标记为宏任务,继续往下执行
接下来遇到了 Promise
,Promise
构造函数中的代码是同步任务,只有 then
回调函数才是异步微任务,所以会立即打印 promise1
和 promise2
而这两个 then
中的回调函数会被放入到微任务队列当中,继续往下执行
紧接着遇到最后同步代码 console
,会打印 script end
上面同步任务执行完了接着执行微任务,按照先进先出的原则依次执行:
async1 end
、promise3
、promise4
本轮事件循环结束,整理下打印顺序为:
第二轮事件循环:
先执行第一个定时器,注意每次事件循环只会执行一次宏任务和所有微任务,setTimeout
是宏任务,内部碰到 console.log('setTimeout1');
接着打印 setTimeout1
好巧不巧又遇到了 Promise
,接着把这两个 then
回调函数放入微任务中去,接着往下执行又遇到了第二个定时器,前面说了,一次事件循环只执行一个宏任务,接着把第二个定时器放到宏任务队列中,此时调用栈是空的(第一个setTimeout 执行完了),内部检查的时候发现微任务队列中有微任务,接着执行清空微任务队列,依旧是先进先出的原则打印 promise in timer1
和 promise in timer2
本轮事件循环结束,打印顺序为:
第三轮事件循环:
最后只剩下一个定时器了,直接执行 console.log('setTimeout2');
打印 setTimeout2
最终打印结果为:
this
为 new
正在创建的新对象。构造函数中,通过强行赋值的方式为新对象添加规定的属性,并保存属性值eslint
已经有了不让 new 的规则,大部分新生 API 都采用 create
方式,比如 vue3typeof 操作符返回一个字符串,表示未经计算的操作数的类型
1 | typeof 1 // 'number' |
从上面例子,前 6 个都是基础数据类型。虽然 typeof null 为 object,但这只是 JavaScript 存在的一个悠久 Bug,不代表 null 就是引用数据类型,并且 null 本身也不是对象
所以,null 在 typeof 之后返回的是有问题的结果,不能作为判断 null 的方法。如果你需要在 if 语句中判断是否为 null,直接通过 ===null 来判断就好
同时,可以发现引用类型数据,用 typeof 来判断的话,除了 function 会被识别出来之外,其余的都输出 object
如果我们想要判断一个变量是否存在,可以使用 typeof:(不能使用 if(a), 若 a 未声明,则报错)
1 | if(typeof a != 'undefined'){ |
instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上
1 | object instanceof constructor |
构造函数通过new可以实例对象,instanceof 能判断这个对象是否是之前那个构造函数生成的对象
1 | // 定义构建函数 |
关于 instanceof 的实现原理,可以参考下面:
1 | function myInstanceof(left, right) { |
也就是顺着原型链去找,直到找到相同的原型对象,返回 true,否则为 false
typeof 与 instanceof 都是判断数据类型的方法,区别如下:
typeof 会返回一个变量的基本类型,instanceof 返回的是一个布尔值
instanceof 可以准确地判断复杂引用数据类型,但是不能正确判断基础数据类型
而 typeof 也存在弊端,它虽然可以判断基础数据类型(null 除外),但是引用数据类型中,除了 function 类型以外,其他的也无法判断
可以看到,上述两种方法都有弊端,并不能满足所有场景的需求
如果需要通用检测数据类型,可以采用 Object.prototype.toString
调用该方法,统一返回格式 [object xxx]
的字符串
如下
1 | Object.prototype.toString({}) // "[object Object]" |
了解了 toString 的基本用法,下面就实现一个全局通用的数据类型判断方法
1 | function getType(obj){ |
splice()
方法通过删除或替换现有元素或者原地添加新的元素来修改数组,并以数组形式返回被修改的内容。此方法会改变原数组。
1 | const months = ['Jan', 'March', 'April', 'June']; |
slice()
方法返回一个新的数组对象,这一对象是一个由 start 和 end 决定的原数组的浅拷贝(包括 begin,不包括end)。原始数组不会被改变。
1 | const animals = ['ant', 'bison', 'camel', 'duck', 'elephant']; |
1 | import Vue from 'vue' |
在 vue2 中注册全局组件、挂载原型、全局插件必须在构建 Vue
实例之前,其实这样非常污染 Vue
实例
1 | const app1 = new Vue({ el: '#app-1' }) |
此时如果创建两个 Vue
实例,会导致每个实例都挂载了相同的插件、全局组件,因为插件的注册是在 new Vue
之前的,即挂载在 Vue
原型上
除了 component
还有以下全局 API
都会影响到 Vue
实例:
Vue.directive()
Vue.mixin()
Vue.use
Vue.config
Vue.prototype
其原因其实是因为 Vue2
版本是没有考虑到多个应用程序的,这使得创建 Vue
的副本非常困难。
构造函数的形式不利于隔离不同的 Vue
实例应用。调用构造函数的静态方法会对所有 Vue
实例应用生效
构造函数的形式不利于 Tree Shaking
,无论我们使用什么功能,它们最终都会出现在生产代码中。主要原因是 Vue
实例在项目中是单例的,捆绑程序无法检测到该对象的哪些属性在代码中被使用到
1 | import Vue from 'vue' |
而 Vue3
引入 Tree Shaking
特性,将全局 API
进行分块。如果你不使用其某些功能,它们将不会包含在你的基础包中
1 | import { nextTick, observable } from 'vue' |
通过 Tree Shaking
,Vue3
给我们带来的好处是:
其次 Vue3
采用 createApp
工厂函数来返回一个 Vue
实例,所有全局 API
修改都通过这个 实例
来挂载处理,这样,多个 createApp
创建的实例,它们之间互相不干扰
1 | const { createApp } = Vue |
两者都可以描述对象或者函数
interface 侧重于描述数据结构,这个结构该有哪些类型的变量
type 侧重描述类型
type 重复定义会报错
interface 重复定义会自动合并
优先使用 interface
vue2 和 vue3 都是在相同的生命周期(beforeCreate
之后、created
之前)完成数据的响应式。
vue2 的响应式对象是通过 Object.defineProperty
对每个属性进行监听,当对属性进行读取的时候,就会触发 getter
,对属性进行设置的时候,就会触发 setter
。
由于 Object.defineProperty
无法监听对象的变化,所以 Vue2 中设置了一个 Observer
类来管理对象的响应式依赖,同时也会递归侦测对象中子数据的变化。
Observer
类的作用就是把一个对象全部转换成响应式对象,包括子属性数据,当对象新增或删除属性的时候负债通知对应的
其实 Object.defineProperty
是可以监听数组的变化的。
首先这种直接通过下标获取数组元素的场景就比较少,其次即便通过了 Object.defineProperty
对数组进行监听,但也监听不了 push、pop、shift
等对数组进行操作的方法,所以还是需要通过对数组原型上的那 7
个方法进行重写监听。所以为了性能考虑 vue2 直接弃用了使用 Object.defineProperty
对数组进行监听的方案。
array[index] = xxx
的形式,更多的是使用数组的 API
的方式data
选项中提前声明好所有元素,比如通过 array[index] = xxx
方式赋值时,一旦 index
的值超过了现有的最大索引值,那么当前的添加的新元素也不会具有响应式getter / setter
push、pop、shift、unshift
等的调用,最终仍需 重写 / 增强 原生方法vue2 对数组的监测是通过重写数组原型上的 7 个方法来实现 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'
看来 vue 能对数组进行监听的原因是,把数组的方法重写了。总结起来就是这几步:
Array
的原型方法,因为拦截后还是需要原生的方法帮我们实现数组的变化。Array
的原型方法使用 Object.defineProperty
做一些拦截操作。Array
类型的数据原型指向改造后原型。详细内容看这篇文章:听说你很了解 Vue3 响应式?
vue3
中提供了 reactive()
和 ref()
两个方法用来将 目标数据
变成 响应式数据
,通过 Proxy
来实现 数据劫持(或代理)
普通对象类型直接配合 Proxy
提供的捕获器实现响应式
数组类型 也可以直接复用大部分和 普通对象类型 的捕获器,但其对应的查找方法和隐式修改 length
的方法仍然需要被 重写 / 增强
原始值数据类型 主要通过 ref()
函数来进行响应式处理,不过内容不会对 原始值类型 使用 reactive()(或 Proxy)
函数来处理,而是在内部自定义 get value(){}
和 set value(){}
的方式实现响应式,毕竟原始值类型的操作无非就是 读取 或 设置,核心还是将 原始值类型 转变为了 普通对象类型
ref()
函数可实现原始值类型转换为 响应式数据,但 ref()
接收的值类型并没只限定为原始值类型,若接收到的是引用类型,还是会将其通过 reactive()
函数的方式转换为响应式数据1 | // vue3的响应式原理 |
proxy 内部使用 Reflect
静态方法来实现对数据的操作
Reflect
是一个内置的对象,它提供拦截 JavaScript
操作的方法,这些方法与 Proxy handler
提供的的方法是一一对应的,且 Reflect
不是一个函数对象,即不能进行实例化,其所有属性和方法都是静态的。
在 Proxy
的 get(target, key, receiver)、set(target, key, newVal, receiver)
的捕获器中都能接到前面所列举的参数:
target
指的是 原始数据对象key
指的是当前操作的 属性名newVal
指的是当前操作接收到的 最新值receiver
指向的是当前操作 正确的上下文正常情况下,receiver
指向的是 当前的代理对象
特殊情况下,receiver
指向的是 引发当前操作的对象
Object.setPrototypeOf()
方法将代理对象 proxy
设置为普通对象 obj
的原型obj.name
访问其不存在的 name
属性,由于原型链的存在,最终会访问到 proxy.name
上,即触发 get
捕获器在 Reflect
的方法中通常只需要传递 target、key、newVal
等,但为了能够处理上述提到的特殊情况,一般也需要传递 receiver
参数,因为 Reflect 方法中传递的 receiver 参数代表执行原始操作时的 this
指向,比如:Reflect.get(target, key , receiver)
、Reflect.set(target, key, newVal, receiver)
。
总结:Reflect
是为了在执行对应的拦截操作的方法时能 传递正确的 this
上下文。
Tree Shaking
是一种通过清除多余代码方式来优化项目打包体积的技术,专业术语叫 Dead code elimination
简单来讲,就是在保持代码运行结果不变的前提下,去除无用的代码
在 Vue2
中,无论我们使用什么功能,它们最终都会出现在生产代码中。主要原因是 Vue
实例在项目中是单例的,捆绑程序无法检测到该对象的哪些属性在代码中被使用到
1 | import Vue from 'vue' |
而 Vue3
源码引入 Tree Shaking
特性,将全局 API
进行分块。如果您不使用其某些功能,它们将不会包含在您的基础包中
1 | import { nextTick } from 'vue' |
vue2 | vue3 |
---|---|
beforeCreate | setup() |
created | setup() |
beforeMount | onBeforeMount |
mounted | onMounted |
beforeUpdate | onBeforeUpdate |
updated | onUpdated |
activeted | onActiveted |
deactiveted | onDeactiveted |
beforeDestory | onBeforeUnmount |
destoryed | onUnmounted |
setup 执行的时机是在 beforeCreate 和 created 之前
vue2 中 v-for 优先级高于 v-if,虽然 vue2 规范中不建议 v-for 和 v-if 同写一行,因为在 循环中+判断 这样会带来性能问题。
vue3 中 v-if 优先级高于 v-for,因为 vue3 觉得 vue2 既然不推荐 v-for 和 v-if 同行,那设置优先级本身没有什么意义。
nextTick
这个方法作用是当数据被修改后使用这个方法,回调函数获取更新后的 DOM
再渲染出来
说明:
nextTick
是一个异步微任务,等待当前函数的 DOM
渲染结束后执行nextTick
类似于一个非常高级的定时器 自动追踪 DOM
更新 更新好了就触发
应用场景
DOM
更新是异步的, vue 响应式的特征是修改数据后页面会自动更新,而更新 DOM
这个操作是异步的 ,这个时候使用 nextTick
回调函数会在下一次 DOM
更新完毕后执行
<template>
进行v-for时,vue3需要把 key
放在 <template>
,而不是把key放在子元素中。(vue2 是把 key 放在子元素)。v-if、v-else-if、v-else
不再需要使用 key
,因为 vue3
会自动给予每个分支一个唯一的 key
。watchEffect
不需要指定监听的属性,他会自动的收集依赖, 只要我们回调中引用到了响应式的属性, 那么当这些属性变更的时候,这个回调都会执行watch
只能监听指定的属性watch
是惰性的,如需组件初始化就执行请携带 immediate: true
参数watch
可以获取到新值与旧值,而 watchEffect
不行watchEffect
在组件初始化的时候就会执行一次用以收集依赖(与computed
同理),后续收集的依赖发生变化,这个回调才会再次执行vue3
组件入口为 setup()
函数作为入口, 默认只执行一次;执行时机在 beforeCreate
和 created
之前
Composition API
函数式开发,很大程度的提高组件、业务逻辑的复用性;高度解耦;提升代码质量、开发效率;减少代码体积Option API
在单文件组件中过长会出现一个逻辑关注点分散在组件各处,形成代码碎片,维护时需要反复横跳回答范例:
vue3 首推 Composition API
,但是这会让我们在代码组织上多花点心思,因此在选择上,如果我们项目属于中低复杂度的场景,Option API
仍然是一个好选择。对于那些大型,高扩展,强维护的项目上, Composition API
会获得更大的收益。
localStorage 持久化存储,或者第三方插件存储
模块化这一块做的过于复杂,用的时候容易出错。比如访问 store
时要带上模块 key
,内嵌模块的话会很长,不得不配合 mapState
,对 ts
支持不友好,使用模块时没有代码提示,pinia
出现之后使用体验好了很多,vue3 + pinia
会是更好的组合
<router-view>
,从而形成物理上的嵌套,和逻辑上的嵌套对应起来。定义嵌套路由时使用 children
属性组织嵌套关系<router-view>
组件内部判断其所处的嵌套层级的深度,将这个深度作为匹配组件数组 matched
的索引,获取对应的渲染组件并渲染之主要区别是在他们的展现形式,url
的显示形式和部署上
hash 模式在地址栏的时候是已哈希的形式:#/xxx
,这种方式使用和部署都比较简单
history 模式 url
看起来更优雅美观,xxx/xx
,但是应用在部署时需要特殊配置,web服务器需要做回退处理,否则会出现刷新页面404的问题
在实现上不管哪种模式都是监听 popstate
事件触发路由跳转处理,url
显示不同只是显示效果上的差异
router
为 VueRouter
的实例,相当于一个全局的路由器对象,里面含有很多属性和子对象,例如 history
对象。经常用的跳转链接就可以用 this.$router.push
和 router-link
跳转一样。
route
相当于当前正在跳转的路由对象。可以从里面获取 name,path,params,query
等属性
query 是显式传值(直接显式在 http://localhost:8080/about?a=1
)
params 是隐式传值
以下三种方式在 Vue3 中均已弃用,详见 Vue3 组件样式变化。
>>>
:适用于css、stylus/deep/
:适用于node-sass、less::v-deep
:适用于dart-sass、node-sass、less、stylus注:截止 2022 年 5 月,以上 3 种旧的深度选择器任能在 vue3 项目中使用,但会有警告提示信息;
vue3 目前最新的样式穿透是 ::v-deep()
简写 :deep()
:深度选择器(样式穿透);
Vue 在创建组件的时候,会给组件生成唯一的 id
值,当 style
标签给 scoped
属性时,会给组件的 html
节点都加上这个 id
值标识,如 data-v4d5aa038
,然后样式表会根据这 id
值标识去匹配样式,从而实现样式隔离
model-view-viewModel(MVVM) 是一个软件架构设计模式,能够实现前端开发和后端业务逻辑的分离,其中
model
指数据模型,负责后端业务逻辑处理view
指视图层,负责前端整个用户界面的实现viewModel
则负责 view
层和 model
层的交互
可以简单的理解为将原来繁重复杂的整个 js
文件按照功能 或者按模块拆成一个个单独的 js
文件,然后将每一个 js
文件中的某些方法抛出去,给别的 js
文件引用和依赖
node.js
采取 commonJS
规范,因为是服务器编程,模块文件一般都已经存在本地硬盘,加载比较快,采用同步加载模块AMD
规范,浏览器环境要从服务器端加载模块,这时就必须采用异步模式,出的早,可以指定回调函数CMD
规范专门用于浏览器端,模块的加载是异步的,模块使用时才会加载执行(延迟加载)。CMD
规范整合了 commonJS
和 AMD
规范的特点commonJS
模块时运行时加载,它输出一个值的拷贝(模块内改变不会影响输出的这个值)se6
模块时编译时输出接口,是输出一个值得引用(引用会改变原值)
AMD(Asynchronous Module Definition):异步模块定义。采用异步方式加载模块,模块的加载不影响后续语句的执行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。requireJS
是一个遵守 AMD
规范工具库,用于客户端的模块管理。requireJS 的基本思想是,通过 define 方法,将代码定义为模块;通过 require 方法,实现代码的模块加载。
1 | // AMD 默认推荐的是 |
简单的理解为 AMD 定义模块必须提前声明
CMD(Common Module Definition):通用模块定义。用于浏览器端,是除 AMD 以外的另一种模块组织规范。结合了 AMD 与 CommonJs 的特点。也是异步加载模块。
与 AMD 不同的是:AMD 推崇的是依赖前置,而 CMD 是依赖就近,延迟执行。
1 | // CMD |
CMD 则是用到打的时候再声明
Node 应用由模块组成,采用 CommonJS 模块规范。每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。在服务器端,模块的加载是运行时同步加载的;在浏览器端,模块需要提前编译打包处理。
CommonJs 有 4
个毕竟重要的变量:module
、require
、exports
、global
ES modules(ESM)是 JavaScript 官方的标准化模块系统。ES6 模块设计的思想是尽量的静态化,使得编译时就能知道模块的依赖关系,以及输入和输出的变量。有两个主要的命令:export 和 import。export 用于对外暴露接口,import 用于引入其他模块。
read-only
特性: import 的属性是只读的,不能赋值,类似于 const 的特性import / export
必须位于 模块顶级
,不能位于作用域内;其次对于模块内的 import / export
会提升到模块顶部,这是在编译阶段完成的值的引用
,输出接口动态绑定,而 CommonJS 输出的是值的拷贝将一个函数作为参数传递给另一个函数,并在函数体内部调用它。所以,被传递给另一个函数作为参数的函数叫作回调函数。比如:setTimeout
1 | const message = function() { |
类是 es6 新增的,是 es5 构造函数的语法糖,在 es5 时期,生成实例是通过构造函数。
es6 中使用 class
关键字声明一个类,之后以这个类来实例化对象。
类抽象了对象的公共部分,它泛指某一大类(class)对象特指某一个,通过类实例化一个具体的对象
接下来就进入正题了,揭开 es6 中 class 的神秘面纱。首先为什么会有 class
的概念,在 es5 时期,生成实例是通过构造函数,但是如果要添加方法的话,就必须在原型上去添加,这样构造函数 new
出来的实例才可以用这个方法。就比如这样:
1 | // Person为构造函数 |
而在 es6
中使用 class
实现
1 | // 类中的this指向创建的实例 |
类抽象了对象的公共部分,它泛指某一大类(class)
对象特指某一个,通过类实例化一个具体的对象
面向对象的思维特点:
抽取(抽象)对象共用的属性和行为组织(封装)成一个类(模板)
对类进行实例化、 获取类的对象
实例:实际的例子、对象
实例化:通过类的构造函数,来创建对象、实例
Foo()
函数是构造函数prototype
属性,指向实例对象的原型对象。通过同一个构造函数实例化的多个对象具有相同的原型对象。经常使用原型对象来实现继承。new
操作创建的对象是实例对象。可以用一个构造函数,构造多个实例对象每一个实例对象都有一个 (原型对象,es6 里叫 [[Prototype]]: Object
)
prototype
中有一个隐式 __proto__
属性(隐式原型),默认值是构造函数的 prototype
__proto__
的作用就是当访问一个对象的属性时,如果该对象内部不存在这个属性,那么就会去它的 __proto__
属性所指向的那个对象(父对象)里找,一直找直到 __proto__
属性的终点 null
,再往上找就相当于在 null
上取值就会报错。
通过 __proto__
属性将对象连接起来的这条链路即我们所谓的原型链。
Object
对象,Object
对象直接继承根源对象 null
Object
对象),都是继承自 Function
对象Object
对象直接继承自 Function
对象Function
对象的 __proto__
会指向自己的原型对象,最终还是继承自 Object
对象var
存在变量提升,js
在预编译的时候会自动将所有代码里面的,var
关键字生命的语句提升到当前作用域的顶端
1 | function person(status) { |
1 | // 上述代码会变成这样 |
es6 带来了块级声明则是 const
let
{}
括号之间有效let
和 var
一样都是声明变量,但是 let
没有变量提升,let
声明的变量只在当前块级有效
1 | function person(status) { |
let
不允许重复声明,会报错
const
声明指的是常量,const 定义必须初始化值否则会报错
1 | const value = "hhhh" |
const
一旦定义了值或对象,那么它的内存地址则不能修改,比如定义一个对象,你可以修改对象里面的属性,但是无法修改对象
1 | const person = { |
答案是报错,如下:Uncaught ReferenceError: Cannot access 'b' before initialization
意思就是在初始化之前无法访问变量 b
1 | function a () { |
var
在全局作用域声明的变量有一种行为会挂载在 window
对象上,这种行为有可能会覆盖 window
的某个属性,而 let、const
则不会有这个行为
1 | var value1 = "张三" |
当时我回答的没有,没有想到面试官问的是 echarts
图表渲染的两种方式, SVG
和 Canvas
。
一般来说,Canvas
更适合绘制图形元素数量较多(这一般是由数据量大导致)的图表(如热力图、地理坐标系或平行坐标系上的大规模线图或散点图等),也利于实现某些视觉 特效。但是,在不少场景中,SVG
具有重要的优势:它的内存占用更低(这对移动端尤其重要)、并且用户使用浏览器内置的缩放功能时不会模糊。
选择哪种渲染器,我们可以根据软硬件环境、数据量、功能需求综合考虑。
Canvas
数量多导致内存占用超出手机承受能力),可以使用 SVG
渲染器来进行改善。大略的说,如果图表运行在低端安卓机,或者我们在使用一些特定图表如 水球图 等,SVG
渲染器可能效果更好。Canvas
渲染器。如何使用渲染器
如果是用如下的方式完整引入 echarts
,代码中已经包含了 SVG 渲染器和 Canvas 渲染器
1 | import * as echarts from 'echarts'; |
如果你是按照 在项目中引入 Apache ECharts 一文中的介绍使用按需引入,则需要手动引入需要的渲染器
1 | import * as echarts from 'echarts/core'; |
然后,我们就可以在代码中,初始化图表实例时,传入参数 选择渲染器类型:
1 | // 使用 Canvas 渲染器(默认) |
闭包让你可以在一个内层函数中访问到其外层函数的作用域
一个函数和词法环境的引用捆绑在一起,这样的组合就是闭包(closure)。
一般就是一个 func A
,return 其内部的 func B
,被 return
出去的 func B
能够在外部访问 func A
内部的变量,这时候就形成了一个 func B
的变量背包, func A
执行结束后这个变量背包也不会被销毁,并且这个变量背包在 func A
外部只能通过 func B
访问。
1 | function A(){ |
闭包形成的原理: 作用域链,当前作用域可以访问上级作用域中的变量。
闭包解决的问题: 能够让函数作用域中的变量在函数执行结束之后不被销毁,同时也能在函数外部可以访问函数内部的局部变量。
闭包带来的问题: 由于垃圾回收器不会将闭包中变量销毁,于是就造成了内存泄露,内存泄露积累多了就容易导致内存溢出,不过这取决与写代码的人。
闭包的应用场景: 防抖函数应用到了闭包,能够模仿块级作用域,能够实现柯里化,在构造函数中定义特权方法、Vue 中数据响应式 Observer 中使用闭包等。
函数作为参数:柯里化函数
柯里化的目的在于避免频繁调用具有相同参数函数的同时,又能够轻松的重用
1 | // 假设我们有一个求长方形面积的函数 |
script
标签存在两个属性,defer
和 async
,因此 script 标签的使用分为三种情况:
1 | <script src="example.js"></script> |
没有 defer
或 async
属性,浏览器会立即加载并执行相应的脚本。也就是说在渲染 script 标签之后的文档之前,不等待后续加载的文档元素,读到就开始加载和执行,此举会阻塞后续文档的加载;
1 | <script async src="example.js"></script> |
有了 async
属性,表示后续文档的加载和渲染与 js 脚本的加载和执行是并行进行的,即异步执行;
1 | <script defer src="example.js"></script> |
有了 defer
属性,加载后续文档的过程和 js 脚本的加载(此时仅加载不执行)是并行进行的(异步),js 脚本的执行需要等到文档所有元素解析完成之后,DOMContentLoaded 事件触发执行之前。
async defer 都是异步的,但是 async
边异步加载边执行,defer 只是异步加载,延迟执行(等文档元素解析完成后再执行)
一张图片就是一个标签,浏览器是否发起请求图片是根据的 src 属性,所以实现懒加载的关键就是,在图片没有进入可视区域时,先不给的 src 赋值,这样浏览器就不会发送请求了,等到图片进入可视区域再给 src 赋值
src
不设置真实的路径data-src
js
判断图片是否进入可视区域src
换成真实路径其他方式已经大致实现懒加载,但是,它们都有一个缺点,就是一当发生滚动事件时,就发生了大量的循环和判断操作判断图片是否可视区里。这自然是不太好的,那是否有解决方法。
这里就引入了一个叫 Intersection Observer 观察器接口,它是是浏览器原生提供的构造函数,使用它能省到大量的循环和判断。当然它的兼容可能不太好,看情况使用。
Intersection Observer
是什么呢?这个构造函数的作用是它能够观察可视窗口与目标元素产生的交叉区域。
简单来说就是当用它观察我们的图片时,当图片出现或者消失在可视窗口,它都能知道并且会执行一个特殊的 回调函数
,我们就利用这个回调函数实现我们的操作。
概念枯燥难懂,直接看下面例子:
1 | const images = document.getElementsByTagName("img"); |
防抖节流本质上是优化高频率执行代码的一种手段
如:浏览器的 resize
、scroll
、keypress
、mousemove
等事件在触发时,会不断地调用绑定在事件上的回调函数,极大地浪费资源,降低前端性能
为了优化体验,需要对这类事件进行调用次数的限制,对此我们就可以采用 防抖(debounce) 和 节流(throttle) 的方式来减少调用频率
一个经典的比喻:
想象每天上班大厦底下的电梯。把电梯完成一次运送,类比为一次函数的执行和响应
假设电梯有两种运行策略 debounce
和 throttle
,超时设定为15秒,不考虑容量限制
电梯第一个人进来后,等待15秒。如果过程中又有人进来,15秒等待重新计时,直到15秒后开始运送,这是防抖
电梯第一个人进来后,15秒后准时运送一次,这是节流
loadsh 调库就完事了
undefined
this
指向全局对象,而函数指向引用对象存储在 state
中,改变 Vuex
中的状态的唯一途径就是显式地提交 (commit) mutation
vuex
变更状态必须显示的提交执行 commit mutations
里的方法
1 | import { useStore } from 'vuex' |
或者在 actions
中进行 mutations
修改 state
1 | import { createStore } from "vuex"; |
组件中使用 dispatch 进行分发 actions。
1 | <template> |
Pinia
则可以直接修改状态,且调试工具 能够记录到每一次的变化
Pinia
可以调用 $patch
方法修改多个 state
中的值,当然也可以修改一个
1 | import { storeA } from '@/piniaStore/storeA' |
也可以在 actions
中修改状态
和 Vuex
不同,Pinia
移除了 mutations
,所以在 actions
中修改 state
就和 Vuex
在 mutations
修改 state
一样。
实这也是我比较推荐的一种修改状态的方式,就像上面说的,这样可以实现整个数据流程都在状态管理器内部,便于管理。
1 | import { defineStore } from "pinia"; |
在组件中调用也不在需要 dispatch
函数,直接调用 store
的方法即可。
1 | import { storeA } from '@/piniaStore/storeA' |
Pinia
可以使用 $reset
将状态重置为初始值。
1 | import { storeA } from '@/piniaStore/storeA' |
其实 Vuex 中的 getters
和 pinia 中的 getters
用法是一致的,用于自动监听对应 state
的变化,从而动态计算返回值(和 vue 中的计算属性差不多),并且 getters
的值也具有缓存特性。
如果项目比较大,使用单一状态库,项目的状态库就会集中到一个大对象上,显得十分臃肿难以维护。所以 Vuex 就允许我们将其分割成模块(modules),每个模块都拥有自己 state、mutations、actions…。而 Pinia 每个状态库本身就是一个模块。
Pinia 没有 modules
,如果想使用多个 store
,直接定义多个 store
传入不同的 id
即可,如:
1 | import { defineStore } from "pinia"; |
Vuex 变更 state
状态必须显示的提交执行 commit mutations
里的方法,或者可以在 actions
中进行 commit mutations
修改 state
,组件里则调用 dispatch('xxx')
分发 actions
Pinia 变更状态直接修改,引入 store
直接点对应属性(但是不建议这么搞,最好对应的数据流程变更都在状态管理器内部,这样更好管理)
另外 Pinia 移除了 mutations
,修改状态放到了 actions
里,外部组件调用引入 `store 后直接点就可以了
getters
上两者都一样,都是自动监听 state
的变化,从而动态计算返回值,和计算属性差不多
Pinia 同时也没有 modules
属性,如果想使用多个 store
,直接定义多个 store
传入不同的 id
即可
Vuex 的 modules
属性一般写在总的入口 index.js
内,里面为 modules
文件里的各个 module
组件使用中 Vuex 需要 vuexStore.state.moduleA.count
1 | import { useStore } from 'vuex' |
而 Pinia 则直接引用具体的 module
,然后调用 module
里面属性
1 | import { useUserStore } from '@/store/modules/user'; |
pending
:等待状态
resolved
:完成状态,调用 resolved()
后会进入 then
rejected
:失败状态,调用 rejected()
后会进入 catch
不可变,一旦调用了 resolved()
或者 rejected()
则会进入对应的 then 或者 catch
如果每个请求有依赖关系就给每个请求包一个 promise
,then 里面可以 return 一个 promise 来防止地狱回调,然后 .then() 链式调用
all
接受一个数组,数组里面是 promise
,等待所有 promise
执行 resolved
才走 then
,如果 resolved
有参数则 then
返回一个结果集,如果有一个 rejected
他就会走 catch
,rejected
优先级比 resolved
高
同步执行
浏览器的事件循环: 执行 js
代码的时候,遇见同步任务,直接推入调用栈中执行,遇到异步任务,将该任务挂起,等到异步任务有返回之后推入到任务队列中,当调用栈中的所有同步任务全部执行完成,将任务队列中的任务按顺序一个一个的推入并执行,重复执行这一系列的行为被称为事件循环。 异步任务又分为宏任务和微任务。
宏任务:任务队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列。
微任务:等宏任务中的主要功能都完成后,渲染引擎不急着去执行下一个宏任务,而是执行当前宏任务中的微任务
宏任务包含: 执行 script
标签内部代码、setTimeout / setInterval、ajax请求、postMessageMessageChannel、setImmediate,I/O(Node.js)
微任务包含: Promise、MutonObserver、Object.observe、process.nextTick(Node.js)
去除 console.log
、关闭 sourceMap(默认关闭)
、cdn
、gzip压缩
(别忘了在 nginx 中配置)
webpack
是先打包再启动开发服务器,vite
是直接启动开发服务器,然后按需编译依赖文件。
ES Modules
,会主动发起请求去获取所需文件。vite 充分利用这点,将开发环境下的模块文件,就作为浏览器要执行的文件,而不是像 webpack 先打包,交给浏览器执行的文件是打包后的;rollup
进行打包,所以,vite 的优势是体现在开发阶段,另外,由于 vite 使用的是 ES Module
,所以代码中不可以使用 CommonJs
;插槽分为 具名插槽,匿名插槽,作用域插槽,在子组件中定义 <slot>
标签,这样就形成一个占位,
其中匿名插槽也是默认插槽,不指定 name
属性
1 | <!-- 子组件 --> |
1 | <!-- 父组件 --> |
具名插槽需要在 <slot name="xxx"></slot>
标签中指定 name
在父组件中使用
1 | <!-- 父组件 --> |
还有个作用域插槽通过 slot
传参
1 | <!-- 子组件内 --> |
1 | <!-- 父组件内: --> |
类比与引用数据类型。如果不用 function return
每个组件的 data
都是内存的同一个地址,那一个数据改变其他也改变了,这当然就不是我们想要的。用 function return 其实就相当于申明了新的变量,相互独立,自然就不会有这样的问题;js 在赋值 object 对象时,是直接一个相同的内存地址。所以为了每个组件的 data 独立,采用了这种方式。
页面第一次进入,钩子的触发顺序
created -> mounted -> activated
退出时触发 deactivated
当再次进入(前进或者后退)时,只触发 activated
事件挂载的方法等,只执行一次的放在 mounted
中;组件每次进去执行的方法放在 activated
中
相同点:他们两者都是观察页面数据变化的。
不同点:computed 只有当依赖的数据变化时才会计算, 当数据没有变化时, 它会读取缓存数据。 watch 每次都需要执行函数。watch 更适用于数据变化时的异步操作。
当需要在数据变化时执行异步或开销较大的操作时,这个方式是最有用的。这是和 computed 最大的区别,请勿滥用。
浏览器默认是标准盒模型。
标准模型
(宽、高)= content 的宽高
1 | /* 标准盒模型 */ |
IE 模型
(宽、高)= content 的宽高 + padding 的宽高 + border 的宽高
1 | /* IE 模型 */ |
官方定义:BFC(Block Formatting Context)块格式化上下文
说人话:BFC 就是页面上的一个隔离的独立容器,容器里面的子元素不会影响到外面的元素。 我们经常使用到 BFC,只不过不知道它是BFC而已。
常用的方式有以下几种:
float
为 left
或者 right
就可以创建 BFC)position
为 absolute
或 fixed
)display:inline-block
,display:table-cell
,display:flex
,display:inline-flex
overflow
指定除了 visible
的值都是创建了一个 BFC特点:
BFC 就是页面上的一个隔离的独立容器,容器里面的子元素不会影响到外面的元素。
如果两个块级元素属于同一个 BFC,它们的上下 margin 会重叠(或者说折叠),以较大的为准。
但是如果两个块级元素分别在不同的 BFC 中,它们的上下边距就不会重叠了,而是两者之和。
解决外边距折叠问题
外边距折叠(Margin collapsing)也只会发生在属于同一 BFC 的块级元素之间。
1 | <div class="div1"></div> |
1 | .div1 { |
对第一个 div 的 margin-bottom
设置为 10px
,
第二个 div 的 margin-top
设置为 20px
,
我们可以看到两个盒子最终的边距是 20px
,是两者之中较大的一个。
这就是外边距重叠的问题。
为了解决这个问题,我们可以让这两个 div 分属于不同的 BFC,或者只要把其中一个 div 放到 BFC 中就可以。
原因是:BFC 就是页面上的一个隔离的独立容器,容器里面的元素不会对外边产生影响。
1 | <div class="wrapper"> |
1 | .wrapper{ |
现在的代码可以解决外边距重叠的问题,但是注意,在我们这个案例中,虽然指定 position 属性为 absolute 和 fixed,或者 float 指定为 left、right 也可以创建 BFC,但是这个元素会从当前文档流中移除,不占据页面空间,并且可以和其它元素重叠。导致下边的 div 会把上边的 div 给覆盖掉。
1 | <div class="outer"> |
1 | .outer { |
1 | .outer { |
查询ip地址
建立tcp连接,接入服务器
浏览器发起http请求
服务器后台操作并做出http响应
网页的解析与渲染
查询ip地址
建立tcp连接,接入服务器
浏览器发起http请求
服务器后台操作并做出http响应
网页的解析与渲染详细步骤如下:
查询ip地址
浏览器解析出url中的域名。
查询浏览器的DNS缓存。
浏览器中没有DNS缓存,则查找本地客户端hosts文件有无对应的ip地址。
hosts中无,则查找本地DNS服务器(运营商提供的DNS服务器)有无对应的DNS缓存。
若本地DNS没有DNS缓存,则向根服务器查询,进行递归查找。
递归查找从顶级域名开始(如.com),一步步缩小范围,最终客户端取得ip地址。
lerna
或到最近兴起的 pnpm
管理 monoreo workspace
。eslint
配合 pretter
确保团队代码格式统一性。commitizen
配合 commitlint
与 lint-staged
与 husky
之间的配合,把关最后提交代码质量与 commit 信息规范。circleci
, github action
或 gitee go
进行CI/CD(持续集成、持续交付和持续部署)。执行 git commit -m 'xxx'
时,用来检查 'xxx'
是否满足固定格式的工具。
为什么使用 commitlint
:团队中规范了 commit
规范可以更清晰的查看每一次代码提交记录,还可以根据自定义的规则,自动生成 changeLog
文件。
基于 Node.js 的 git commit
命令行工具,辅助生成标准化规范化的 commit message
,是一个平台,具体职能交给适配器。
更换 commitizen
命令行工具的交互方式插件。像 cz-emoji
、cz-emoji-chinese
、cz-conventional-changelog
、cz-customizable
,这些都是适配器,至于选择哪个,看个人喜好选择。
本文使用 cz-git
来作为适配器,理由请看作者博客:cz-git 友好型 commitizen 的适配器
全局安装 commitizen
,如此一来可以快速使用 cz
或 git cz
命令进行启动。 1
npm install -g commitizen
cz-git
。1 | pnpm install -D cz-git |
package.json
添加 config 字段,为 commitizen
指定 cz-git
适配器1 | // package.json |
commitlint
安装 @commitlint/config-conventional
@commitlint/cli
@commitlint/cli
是 commitlint
提供的命令行工具,安装后会将 cli
脚本放置在 ./node_modules/.bin/
目录下。@commitlint/config-conventional
是社区中一些共享的配置,是根据 Angular 提交规范预定义的规则包,我们可以扩展这些配置,也可以不安装这个包自定义配置,commitlint
推荐我们使用 @commitlint/config-conventional
配置去写 commit
1 | pnpm install -D @commitlint/cli @commitlint/config-conventional |
commitlint.config.js
文件安装完成之后,在项目根目录创建 commitlint.config.js 文件,来配置 lint
规则:
cz-git
有多种模板,例如: (⇒ 配置模板),这里使用 中英对照 + Emoji 模板,虽然官方并不推荐这么干😆
1 | // commitlint.config.js |
在前端工程化配置(上)构建代码检查工作流中已经安装 husky
,这里直接添加 commit hook
钩子
1 | pnpx husky add .husky/commit-msg 'npx --no-install commitlint --edit "$1"' |
到这里,我们设置好了 commit hook
,在 commit
阶段就会先执行 .husky/commit-msg
下面的命令,这里的 .husky/commit-msg
命令是去执行 commitlint
,检查 commit message
是否符合 commitlint.config.js
文件下的配置规则,这时,如果有人 commit
不符合规范的 message
就会提示错误,退出 commit
操作
放入暂存区(git add .
)以后,执行 git cz
启动 cz-git
适配器,它会读取你的 commitlint.config.js
配置,确认提交后会先触发 pre-commit
hook 执行 lint-staged
检查暂存区的代码格式是否规范并修改,之后再触发 commit-msg
hook。
如果你使用原生 gitcommit -m xxx
会触发 commit-msg
hook,会直接打断提交,因为不符合 commitlint
规范。
最后 git push
即可。
最后,查看 github
,发现已经按照 commitlint
规范提交上去了。
组件库代码规范husky+lint-staged+Eslint+Prettier+Stylelint
利用husky,cz-git,@commitlint/cli规范commit message
]]>在团队开发时,为了保证每个人提交的代码格式统一,采用 husky + lint-staged
配置 git hooks
,自动触发格式化操作,对通过 git add
命令添加到暂存区的代码进行格式化。
在介绍 husky
之前,我们先来看什么是 git hook
,也就是常说的 Git
钩子。
和其它版本控制系统一样,Git
能在特定的重要动作发生时触发自定义脚本。有两组这样的钩子:客户端的和服务器端的。 客户端钩子由诸如提交和合并这样的操作所调用,而服务器端钩子作用于诸如接收被推送的提交这样的联网操作。 你可以随心所欲地运用这些钩子。
其中,客户端钩子我们可能用的比较多,客户端钩子通常包括了提交工作流钩子、电子邮件工作流钩子和其它钩子。这些钩子通常存储在项目的 .git/hooks
目录下,我们需要关注的主要是提交工作流钩子。提交工作流钩子主要包括了以下四种:
pre-commit:该钩子在键入提交信息前运行。 它用于检查即将提交的快照。如果该钩子以非零值退出,Git 将放弃此次提交,你可以利用该钩子,来检查代码风格是否一致。
prepare-commit-msg:该钩子在启动提交信息编辑器之前,默认信息被创建之后运行。 它允许你编辑提交者所看到的默认信息。
commit-msg:该钩子接收一个参数,此参数存有当前提交信息的临时文件的路径。 如果该钩子脚本以非零值退出,Git 将放弃提交,因此,可以用来在提交通过前验证项目状态或提交信息。
post-commit:该钩子一般用于通知之类的事情。
在上面的钩子中,我们需要关注 pre-commit
和 commit-msg
钩子。
husky 是常见的 git hook
工具,使用 husky
可以挂载 Git
钩子,当我们本地进行 git commit
或 git push
等操作前,能够执行其它一些操作,比如进行 ESLint
检查,如果不通过,就不允许 commit
或 push
。
在 pre-commit
hook 中,一般来说都是对当前要 commit
的文件进行校验、格式化等,而不是对全局文件校验,因此在脚本中我们需要知道当前在 Git
暂存区的文件有哪些,而 Git
本身也没有向 pre-commit
脚本传递相关参数,Lint-staged这个包为我们解决了这个问题。简单说就是当我们触发 pre-commit
hook 中的脚本命令后,配合 Lint-staged
的配置可以只检查暂存区的文件从而避免我们每次检查都把整个项目的代码都检查一遍的尴尬情况。其次,Lint-staged
允许指定不同类型后缀文件执行不同指令的操作,并且可以按步骤再额外执行一些其它 shell
指令。
lint-staged 是如何知道当前暂存区有哪些文件的?
事实上,lint-staged 内部也没有什么黑魔法,它在内部运行了 git diff --staged --diff-filter=ACMR --name-only -z
命令,这个命令会返回暂存区的文件信息,类似如下所示的代码:
1 | const { execSync } = require('child_process'); |
husky
1 | pnpm install -D husky |
1 | pnpx husky install |
执行完之后项目根目录会多一个 .husky
文件夹
husky install 命令告诉 Git 改为使用 .husky/Git hooks 目录。
1 | npm pkg set scripts.prepare="husky install" |
此时你的 package.json
应该有:
1 | // package.json |
lint-staged
1 | pnpm install -D lint-staged |
lint-staged
配置首先明确一下,lint-staged 仅仅是文件过滤器,不会帮你格式化任何东西,所以没有代码规则配置文件,需要自己配置一下,如:.eslintrc
、.stylelintrc
等,然后在package.json
中引入。
其中这段的意思是对特定的文件进行特定的格式化。
1 | // package.json |
scripts
中增加如下命令1 | // package.json |
创建 pre-commit
hook
1 | pnpx husky add .husky/pre-commit "npm run lint:lint-staged" |
执行完之后在 .husky
目录下会多一个 pre-commit
脚本文件,在 git commit -m "xxx"
时就会触发 lint:lint-staged
,如果 lint:lint-staged
命令失败,提交将自动中止。
1 | pre-commit |
后期添加其它命令请直接:
1 | pnpx husky add .husky/pre-commit "xxxx" |
比如 添加vue-tsc
,来对你的 *.vue
文件做类型检查。
vue-tsc
1 | pnpm install -D vue-tsc |
scripts
中增加如下命令1 | // package.json |
ts:check
命令设置到 hook 里去1 | pnpx husky add .husky/pre-commit "npm run ts:check" |
同样 pre-commit
钩子会新增一行 npm run ts:check
。
1 | !/usr/bin/sh |
当我们进行一次 git
提交时 =>
触发 husky
配置的 pre-commit
钩子 =>
执行 npm run lint:lint-staged
命令 =>
触发 lint-staged
对暂存区的文件进行格式化 =>
使用 eslint/prettier/stylelint
进行格式化
前面在 pre-commit
钩子中已经设置了 tsc
检查,发现在代码提交时会自动全局检测,这个 google 了一下似乎还没有解决办法,如何只检测暂存区的 ts
规范。
关于 tsc
的问题参考了以下文章:
添加 tsc 的检查
Vue & TypeScript 增量编译
它会读取 package.json
中 lint-staged
配置项帮你格式化。
但是 ts
类型有问题的,它并不会帮你修改(因为你没告诉它怎么做😆),它会直接阻断你的提交,直到你修改完所有类型错误。
如果 git hooks
脚本运行失败(进程结束时返回的状态码不为 0
),那么会终止后续操作。比如上例中 tsc
检查报错,那么会直接终止 commit
,git commit
命令失败。
今天在提交代码的时候 commit 描述里多了一个空格,强迫症患者表示不能忍受
有时你提交过代码之后,发现一个地方改错了,你下次提交时不想保留上一次的记录;或者你上一次的commit message的描述有误,这时候你可以使用这个命令:
1 git commit --amend
但是,如果你想修改上上次,甚至上上上次
你需要先使用这个命令
1 | git rebase -i HEAD~2 |
接上图,其实我这个提交本来是上次的,结果我在提交 本次提交
之前,在 github
上直接修改过 README.md
,然后我在 git push
后顺便 git pull
(同步更改)了,就导致我的这个提交变成上上次了。
所以我需要先使用这个命令 git rebase -i HEAD~2
如果是上上上次 后面数字以此类推
之后你会看到这个画面
上面是显示你最近的两次提交记录,找到你需要的那个 commit
,进入编辑模式 i
把前面的 pick
改成 edit
,最后 wq
保存
现在你再执行 git commit --amend
修改,同样是进入 vim
,进入编辑模式 i
改好后 wq
保存
然后执行 git rebase --continue
你会得到如下提示,表示你操作成功
然后现在 git log
一下,会发现你已经修改成功
最后一步,强制推送到远程库 git push -f
推送成功!
然后进入github远程库查看:
已经修改了提交描述信息,且原来的 git
版本没有了
但是有个地方要注意,就是该操作会改变你原来的 commit id
这个变基操作在多人开发下一定要谨慎使用
1 | <input v-model="name" /> |
v-model 是本质上是一个语法糖,上面的代码其实等价于下面这段 (编译器会对 v-model
进行展开):
1 | <input |
这样最简单的一个双向绑定就实现了,在输入框里更改内容,setup 中的 name 也会改变,数据改变视图也会相应改变
1 | <!-- parent.vue --> |
1 | // child.vue |
v-model
用在自定义组件上时,v-model默认绑定的 prop
名是 modelValue
事件也由 @input
更改为@update: modelValue
vue3 移除了 model 选项,这样就无法在组件内修改默认 prop 名,如果要修改默认 prop 名,可以通过给 v-model:xxx
指定一个参数来更改名字:
1 | <child v-model:name="name" /> |
在这个例子中,子组件应声明一个 name
prop,并通过触发 update:name
事件更新父组件值:
1 | <script setup lang="ts"> |
vue3 同时也移除了 .sync
修饰符,我们先来回顾一下.sync
的作用:
.sync
可以简单的理解为:可以同时双向绑定多个 prop
,而并不像 v-model
那样,一个组件只能有一个。
.sync 本质
1 | <!--语法糖.sync--> |
v-bind.sync
在 2.x 中的使用情况相当混乱, 因为用户以为可以像 v-bind
一样使用它 (完全不看我们在文档中写的). 我认为最好的解释就是:
认为
v-bind:title.sync="title"
是一种具有额外功能的正常绑定是错误的想法, 因为这跟双向绑定完全不同..sync
修饰符的工作原理就像另一种用于创建双向绑定的语法糖v-model
. 主要区别在于.sync
可以在单个组件上定义多个双向绑定, 但v-model
只能定义一个.
那么问题来了: 如果告诉用户不要将 v-bind.sync
认为是 v-bind
, 应该把它认为是 v-model
, 那它是不是应该成为 v-model
的一部分 ?
…
现在,在 vue3 里,你可以在组件上书写多个 v-model
来替代 .sync
了
1 | <my-component |
1 | const first = ref('王'); |
1 | // my-component.vue |
1 | <template> |
vue2 和 vue3 中 v-model 区别:
@input
更改为@update: modelValue
v-model:xxx
指定一个参数来更改名字.sync
修饰符,你可以在组件上书写多个 v-model 来替代 .syncFunction.prototype.apply()
、Function.prototype.call()
call()
和 apply()
是 Function
的方法,它的第一个参数是 this
,第二个参数是是给 调用函数 传递的参数。call
和 apply()
都是为了改变某个函数运行时的 context
即上下文而存在的。
换句话说,就是为了改变函数体内部 this
的指向。
call()
需要把参数按顺序传递进去,而 apply()
则是 把参数放在数组里。他俩都是调用后立刻执行的
例如,有一个函数 fun
定义如下:
1 | window.color = "red"; |
其中
第一个参数 this
是你想指定的上下文,他可以任何一个 js
对象。
第二个参数是给 调用函数
传递的参数(如果调用函数没有参数就不传)。
如果 call()
方法没有参数,或者参数为 null
或 undefined
,则等同于指向全局对象。另外箭头函数无法使用 call()
apply()
bind()
,因为箭头函数中的 this
永远指向函数外最近的那个 this
,因此不会起作用。
返回值:使用调用者提供的 this
值和参数调用该函数的返回值。若该方法没有返回值,则返回 undefined
。
有参数的情况下
1 | window.name = 'mark' |
JavaScript 中,某个函数的参数数量是不固定的,因此要说适用条件的话,当你的参数明确知道数量时,用call()
,而不确定的时候,用apply()
,然后把参数 push
进数组传递进去。
Function.prototype.bind()
bind()
方法返回一个绑定了 this
的新函数(原函数的拷贝),在 bind()
被调用时,这个新函数的 this
被指定为bind()
的第一个参数,而其余参数将作为新函数的参数,供调用时使用。
call()
和 apply()
它们两个是改变 this
的指向之后立即调用该函数,而 bind()
则不同,它是创建一个新函数,我们必须手动去调用它。
通俗讲就是通过bind()
方法,强制绑定了 this
,绑定后的 this
不可改变
示例1:
示例2:
1 | const d = new Date(); |
上面代码中,我们将 d.getTime()
方法赋给变量 print
,然后调用 print()
就报错了。这是因为 getTime()
方法内部的 this
,绑定 Date
对象的实例,赋给变量 print
以后,内部的 this
已经不指向 Date
对象的实例了。
bind()
方法可以解决这个问题。
1 | const print = d.getTime.bind(d); |
上面代码中,bind()
方法将 getTime()
方法内部的 this
绑定到 d
对象,这时就可以安全地将这个方法赋值给其他变量了。
bind
方法的参数就是所要绑定 this
的对象,下面是一个更清晰的例子。
1 | const counter = { |
上面代码中,counter.inc
方法被赋值给变量 func
。这时必须用 bind()
方法将 inc()
内部的 this
绑定到 counter
,否则则会报错
this
绑定其他对象也是可以的
1 | const counter = { |
上面的代码中,bind()
方法将 inc()
内部的 this
绑定到 obj
对象。调用 func()
后,递增的就是 obj
内部的 count
属性。
bind()
还可以接受更多的参数,将这些参数绑定原函数的参数。
1 | const add = function (x, y) { |
上面代码中,bind()
方法除了绑定 this
对象,还将 add()
函数的第一个参数 x
绑定成 5
,然后返回一个新函数 newAdd()
,这个函数只要再接受一个参数 y
就能运行了。
类型别名用来给一个类型起个新名字,使用 type 创建类型别名,类型别名不仅可以用来表示基本类型,还可以用来表示对象类型、联合类型、元组和交集
1 | // 基本类型 |
接口是命名数据结构(例如对象)的另一种方式;与type 不同,interface仅限于描述对象类型,接口的声明语法也不同于类型别名的声明语法。
1 | interface Person { |
1 | interface Point { |
扩展 (继承) 语法:interface 使用 extends,type 使用 &
1 | // extends方式不同 |
两者也可以互相继承
1 | // interface 继承 type |
interface 支持,type 不支持(如果系统中存在两个使用 interface 定义的接口且同名的话,系统则会自动合并它们),如果存在两个同名 type 则会报错
1 | // interface可以定义多次,并将被视为单个接口 |
对象、函数两者都适用,但是 type 可以用于基础类型描述、联合类型、元组(简单类型用 type 来定义)
1 | type test = number //基本类型 |
type 支持计算属性,生成映射类型,interface 不支持
场景: 有时候一个类型需要基于另外一个类型,但是你又不想拷贝一份,这个时候你可以考虑使用映射类型
1 | // keys 联合类型 |
interface 和 type 都可以实现一个函数的类型,但是 interface 可以被 类class
实现(implements),type 不行
1 | interface SetPerson { |
type 可以结合 typeof
使用,interface 不行
1 | class Config { |
虽然 官方 中说几乎接口的所有特性都可以通过类型别名来实现,但建议优先选择接口,接口满足不了再使用类型别名,在 typescript 官网 Preferring Interfaces Over Intersections 有说明,具体内容如下:
大多数时候,对象类型的 简单 类型别名
的作用与 接口
非常相似
1 | interface Foo { prop: string } |
但是,一旦你需要组合两个或多个类型来实现其他类型时,你就可以选择使用接口扩展这些类型,或者使用类型别名将它们交叉在一个中(交叉类型),这就是差异开始的时候。
接口创建一个单一的平面对象类型来检测属性冲突,这通常很重要! 而交叉类型只是递归的进行属性合并,在某种情况下可能产生 never 类型
接口也始终显示得更好,而交叉类型做为其他交叉类型的一部分时,直观上表现不出来,还是会认为是不同基本类型的组合。
接口之间的类型关系会被缓存,而交叉类型会被看成组合起来的一个整体。
最后一个值得注意的区别是,在检查到目标类型之前会先检查每一个组分。
出于这个原因,建议使用 接口/扩展interface/extends
扩展类型而不是创建 交叉类型&
。
1 | - type Foo = Bar & Baz & { |
简单的说,接口更加符合 JavaScript 对象的工作方式,简单的说明下,当出现属性冲突时:
1 | // 接口扩展 |
以上内容均来自 Google
]]>在深入渲染函数之前,了解一些浏览器的工作原理是很重要的。以下面这段 HTML 为例:
1 | <div> |
当浏览器读到这些代码时,它会建立一个 ”DOM 节点“ 树 来保持追踪所有内容,如同你会画一张家谱树来追踪家庭成员的发展一样。
上述 HTML 对应的 DOM 节点树如下图所示
每个元素都是一个节点。
每段文字也是一个节点。
甚至注释也都是节点。
一个节点就是页面的一个部分。
就像家谱树一样,每个节点都可以有孩子节点 (也就是说每个部分可以包含其它的一些部分)。
高效地更新所有这些节点会是比较困难的,不过所幸你不必手动完成这个工作。你只需要告诉 Vue 你希望页面上的 HTML 是什么,这可以是在一个模板里:
1 | <h1>{{ blogTitle }}</h1> |
或者一个渲染函数里:
1 | render() { |
在这两种情况下,Vue 都会自动保持页面的更新,即便 blogTitle
发生了改变。
Vue 通过建立一个虚拟 DOM 来追踪自己要如何改变真实 DOM。请仔细看这行代码:
1 | return h('h1', {}, this.blogTitle) |
h()
到底会返回什么呢?其实不是一个实际的 DOM 元素。它更准确的名字可能是 createNodeDescription
,因为它所包含的信息会告诉 Vue 页面上需要渲染什么样的节点,及其子节点的描述信息。我们把这样的节点描述为“虚拟节点 (virtual node)”,也常简写它为 VNode。“虚拟 DOM”是我们对由 Vue 组件树建立起来的整个 VNode 树的称呼。
h()
参数h()
函数是一个用于创建 VNode 的实用程序。也许可以更准确地将其命名为 createVNode()
,但由于频繁使用和简洁,它被称为 h()
。它接受三个参数:
1 | // @returns {VNode} |
如果没有 prop,那么通常可以将 children 作为第二个参数传入。如果会产生歧义,可以将 null
作为第二个参数传入,将 children 作为第三个参数传入。
1 | <template> |
1 | <script setup lang="ts"> |
1 | <template> |
1 | <script setup lang="ts"> |
在写这段的时候遇到了一个警告
“Non-function value encountered for default slot. Prefer function slots for better performance.”
我在 stackoverflow 找到同样的问题,但是我没太明白导致这个原因(有大佬明白请评论指点🙏)
This is inefficient because the child slot is rendered before the HelloWorld component could even use it. The child slot is essentially rendered in the parent, and then passed to the child. Wrapping the child slot generation in a function defers the work until the child is rendered.
机翻:
这是低效的,因为子槽在HelloWorld组件使用它之前就已经呈现了。子槽实际上是在父槽中呈现的,然后传递给子槽。在函数中包装子槽生成会推迟工作,直到渲染子槽为止。
解决方案
与其在父级中渲染子槽(即,直接传递一个数组 VNodes 作为 slots 参数),不如将其包装在一个函数中:
1 | // src/components/Composite.js |
线上 demo
https://codesandbox.io/s/hardcore-gould-ho47tm?file=/src/components/Composite.js
1 | <script setup lang="ts"> |
在 setup script 中书写 h() 函数只是一个特殊的实现,此方法不应优先使用,因为它会使代码的可读性降低。 而且setup script
的出现是为了解决setup()
需要导出的麻烦。如果你的组件不需要<template>
,你根本不需要setup script
,你应该使用jsx/tsx
文件
1 | <n-tag round :bordered="false" type="success"> |
1 | render() { |
使用 jsx/tsx
要在 script
标签上声明 lang="tsx"
/ lang="jsx"
1 | <script lang="tsx"></script> |
1 | render() { |
基于 CSS Grid,响应式,远离 IE
'screen'
根据屏幕断点进行响应式布局
屏幕断点分为
断点前缀 | 最小宽度 | css |
---|---|---|
s | 640px | @media (min-width: 640px) { … } |
m | 768px | @media (min-width: 768px) { … } |
l | 1024px | @media (min-width: 1024px) { … } |
xl | 1280px | @media (min-width: 1280px) { … } |
2xl | 1536px | @media (min-width: 1536px) { … } |
1 | <n-grid |
先看下Grid
的 ApI
名称 | 类型 | 默认值 | 说明 |
---|---|---|---|
cols | number | ResponsiveDescription | 24 | 显示栅格的数量 |
collapsed | boolean | false | 是否默认折叠 |
collapsed-rows | number | 1 | 默认展示的行数 |
responsive | ‘self’ | ‘screen’ | self | ‘self’ 根据自身宽度进行响应式布局,’screen’ 根据屏幕断点进行响应式布局 |
item-responsive | boolean | false | 子元素是否可具有响应式宽度 |
x-gap | number | ResponsiveDescription | 0 | 横向间隔槽 |
y-gap | number | ResponsiveDescription | 0 | 纵向间隔槽 |
cols 默认显示 24 个栅格
1 | <n-grid cols="0 s:1 m:1 l:12 xl:12 2xl:24"></n-grid> |
综上
名称 | 类型 | 默认值 | 说明 |
---|---|---|---|
offset | number | ResponsiveDescription | 0 | 栅格左侧的间隔格数 |
span | number | ResponsiveDescription | 1 | 栅格占据的列数,为 0 的时候会隐藏 |
suffix | boolean | false | 栅格后缀 |
1 | // `n-grid-item` 可以被简写为 `n-gi` |
这段和grid
基本一个意思
综上
]]>