<![CDATA[zairesinatra]]>https://zairesinatra.github.io//https://zairesinatra.github.io//favicon.pngzairesinatrahttps://zairesinatra.github.io//Ghost 5.53Fri, 28 Jul 2023 15:54:52 GMT60<![CDATA[Vim & Neovim]]>https://zairesinatra.github.io//vim-neovim/63fb98a025f4e10bb6ad369eMon, 12 Dec 2022 14:30:00 GMT

Quick Start

Intro to Vim

Vim & Neovim

Vim is a highly configurable text editor built to make creating and changing any kind of text very efficient.

作为一款 Modal Editor,Vim 在不同模式下具有不同的目的和操作。

The default mode for Vim is normal mode, which is a command mode in which you can type execute commands (like deleting text, moving the cursor, etc).

Since the above commands can be triggered by pressing keys on the keyboard, there is no need to enter a colon or other special characters. Therefore, normal mode could be seen as a command mode for executing various editing commands.

Once in insert mode, typing inserts characters just like a regular text editor. You could enter this mode by pressing the i key (to insert text before the cursor), the a key (to insert text after the cursor), or some other command.

Visual mode is used to highlight selected text. You could use visual mode to select text and perform operations such as copy, delete, and paste.

Command mode has a wide variety of commands and can do things that normal mode can’t do as easily. To enter command mode type ":" from normal mode and then type your command which should appear at the bottom of the window.

有从 ThePrimeagen 的 "Vim As Your Editor" 系列中收获到了一些帮助记忆 Vim 的概念:Command Count Motion、Horizontal Movements 和 Vertical Movements。

Like many vim commands, motion can be prefixed by a number to move several steps at a time:

  • j|k means move one row down|up => 6k means move 6 rows up
  • h|l means move one character left|right => 5l means move 5 characters right
  • w|b means hop over|back by word => 4w means hop over 4 words
  • e means move to end of word => 3e means move to end of 3 words
  • 0|$ move to the beginning|end of the line => 2$ means move to the end of the next line

"Command" in "Command Count Motion" means d, c, v, y etc operation commands.

  • d3w means delete 3 words from the current cursor
  • c2$ means to modify all characters from the current cursor to the end of the next line
  • v means to enter the visual mode, appending count and motion can set the highlighted area
  • y0 yanks from cursor position to the beginning of the line, y_ yanks the entire current line

注意:当执行删除、剪切或修改命令时,Vim 默认会将删除的文本存储到 "0 寄存器。寄存器 "+ 表示系统剪贴板寄存器。通过 "+y 可以将选中的文本复制到系统剪贴板,而 "+p 则是将系统剪贴板中的文本粘贴到 Vim。命令 :reg 可以查看所有的寄存器及其内容。

In Vim, g is not a single operation, but a command prefix. The g command prefix can be combined with other commands to achieve various operations. For example:

  • gj: move down one screen, but only count one line
  • gk: move up one screen, but only count one line
  • gg: Move to the first line of the document
  • G: Move to the last line of the document
  • g_: Move to the last non-blank character of the current line
  • gm: Move to the first non-blank character of the middle line of the document

键入 forward slash / 可进入 search 模式。在输入完搜索内容后通过 return or enter 确定,向下切换可以使用 n

除上述外,再补充一些 Vim 中常用的键盘命令:

  • x means delete the character at the current cursor position
  • :%y+ could copy the entire file to the system clipboard command
  • f is used to search forward for the specified character in the current line (eg: f{)
  • zz means to center this line, usually with Ctrl + u|d to scroll up|down half a page

A Vim Session can open multiple tabs, and each tab can contain multiple windows, but each window can only display one buffer. A buffer usually refers to the contents of a file. - ChatGPT

Vim Session 是指编辑器的工作区域,其通常会包含有打开的文件、窗口的布局以及命令历史等信息:

  • 通过 :mksession 可以创建一个 Vim session,并将当前编辑器的状态保存到一个文件中
  • 使用 vim -S 可以打开一个保存了 session 信息的文件,以恢复之前的编辑器状态

A buffer is an area of Vim's memory used to hold text read from a file. In addition, an empty buffer with no associated file can be created to allow the entry of text. – vim.wikia

在 Vim 中,buffer 实际上并不是文件本身,而是文件内容的内存副本。在打开文件时,Vim 会将文件的内容加载进一个 buffer,再开始相关的编辑和修改工作。在进行编辑和修改时,buffer 中的内容是实时更新的。而保存文件后,Vim 会将 buffer 里的内容写回到文件中,以更新文件的实际内容。

Common commands for Vim session, tab, window and buffer are as follows:

  • :e 将文件加载进 buffer
  • :w 将 buffer 内容写入文件
  • :q 关闭 buffer
  • :ls 列出所有的 buffer
  • :vsplit 在当前 window 的侧边打开一个新的 window
  • :bnext | :bprev 切换到下一个或前一个 buffer
  • :tabnext | :tabprev 在不同的 tab 之间进行切换

在 Vim 中可以通过可视块模式 visual block mode 对块文本执行特定的操作:

逐行操作:键入 Ctrl + v 激活可视块模式,使用方向键选择好需要操作的行与列。如果需要逐行插入或删除文本,可以使用 I 进入可视块中的插入模式,或按 A 进入追加模式。在执行这项操作后,默认会取消高亮的块区域并将光标带到顶行。这里注意,在光标所处的顶行执行想要的批量操作,然后按退出键。最后这项操作将反映在之前可视块区所选择的的每一行中。

数字递增:以 1 到 100 为例:插入模式下在第一行输入数字 1,然后按 Enter 将光标移动到下一行后退出插入模式。在第二行键入 y1j 再键入 100p 即可在第三行及其后生成 100 行 1。在第四行(从第三行开始的下一个 1)使用 Ctrl + v 进入可视块模式后输入 99j 即可选择后续的 99 个 1。按下 gCtrl + a 可对后续的 1 进行递增。至此完成从 1-100 递增。

代码块缩进:用 Ctrl + v 与方向键选择好需要缩进的块区域,再通过 Shift + ><< 进行缩进。注意,上述的操作只会缩进一次。如果需要连续缩进,可以按下 . 键来重复上一次的操作。

Vim 在保存未命名的文件时会出现 "E32:No file name" 的提示,可以使用 :file filename 命令来设置当前文件的文件名。

在设置 Vim 主题时,可以通过 cterm 和 gui 选项来分别指定在终端和图形用户界面下的颜色方案:

  • cterm 即 Console Terminal,通常在命令行或终端窗口中使用 Vim 时生效
  • gui 即 Graphical User Interface,通常在使用 GVim 或 MacVim 等 GUI 版本的 Vim 时生效
if has("gui_running")
  " 设置图形用户界面下的颜色
  set background=dark
  colorscheme solarized
else
  " 设置终端下的颜色
  set t_Co=256
  colorscheme desert256
endif

Neovim Tips

Nerd Fonts patches developer targeted fonts with a high number of glyphs (icons). Specifically to add a high number of extra glyphs from popular ‘iconic fonts’ such as Font Awesome, Devicons, Octicons, and others.

通常终端的字符集只会包含一组基本的字符,对于一些图标和特殊符号的处理并不能够满足指定的需求,为了有效解决终端图标乱码的问题,需要使用 Nerd Fonts 这款开源字体集合(对终端设置)。

brew tap homebrew/cask-fonts && brew install --cask font-hack-nerd-font

Test whether the terminal font is set successfully => Click Here.

Mac fonts are stored in ~/Library/Fonts/ by default.

Neovim 的配置文件是用户 Home 目录中的 ~/.config/nvim/init.luainit.vim 文件。

nvim_set_keymap({mode}, {lhs}, {rhs}, {*opts}) sets a global mapping for the given mode.

Neovim 中可通过 nvim_set_keymap()vim.api.nvim_set_keymap() 来设置快捷键:

  • nvim_set_keymap() 是 Neovim 自带的 API 函数,可以直接在 init.vim 或 init.lua 中使用
  • vim.api.nvim_set_keymap() 是通过 Vim 中 Lua API 调用的函数,需要在 Lua 脚本中使用
-- 左右Tab切换
vim.api.nvim_set_keymap("n", "<C-h>", ":BufferLineCyclePrev<CR>", {noremap = true, silent = true })
vim.api.nvim_set_keymap("n", "<C-l>", ":BufferLineCycleNext<CR>", {noremap = true, silent = true })

上述 Quote 中 mode 表示映射模式,即普通、可视、插入模式等;lhs 表示要映射的键位序列,常有特殊、控制、功能键位等;rhs 表示要执行的操作,如命令、Lua 函数等;opts 是一个选项表,可以指定为 nowait、silent、expr 等一个或者多个。

-- 将 <Leader>q 映射为关闭当前窗口
vim.keymap.set("", "<Leader>q", "<C-w>c", { nowait = true })
-- 将 <Leader>n 映射为跳转到下一个 QuickFix 错误
vim.keymap.set("", "<Leader>n", ":cnext<CR>", { silent = true })
-- 将 <Leader>e 映射为在侧边栏打开文件资源管理器
vim.keymap.set("", "<Leader>e", function()
  vim.cmd("NvimTreeToggle")
end)

注意,Neovim 会暴露一个全局的 vim 模块来作为 Lua 调用 Vim 中 APIs 的入口。

可通过 :echo mapleader 查看 <leader> 的映射,默认情况下 <leader> 的映射为反斜杠 \

在 Neovim 中输出某个设置属性的具体值,可以使用 :echo &option_name 命令,&option_name 为设置属性的名称。符号 & 通常用于引用某个选项的值。当使用 &option_name 语法时,Vim 和 Neovim 会将 &option_name 解释为相应选项的名称,并将其替换为该选项的当前值。如果想查看所有可用的选项及其当前值,可以执行 :options 命令。

需要注意的是,并非所有的选项都可以通过上述语法来获取相应的值。通常来说,只有那些被显式地设置为选项的变量才可以使用,其他变量的值可能需要使用一些额外的语法或函数来获取。举个例子,对于 vim.gvim.keymap,使用 echo &... 输出是无效的,因为它们都不是 Vim 的选项。

:echo g:my_setting # 输出 vim.g 中的 my_setting 变量的值

回车 Carriage Return 常缩写为 CR,即对应 Vim 中的 <CR><C-a><C-n> 分别表示 Ctrl+aCtrl+n 键。

Neovim Config From Scratch

Plugin Manager for Neovim

目前 Neovim 中常见的插件管理器有 vim-plug、packer.nvim 和 LazyVim:

  • vim-plug 使用简单,且同时支持 Vim 和 Neovim,所有功能在一个 Vim 脚本中实现
  • Packer.nvim 支持插件间的依赖,通过 lua 编写,专门针对 Neovim v0.5.0 以上版本使用
  • LazyVim 利用缓存提升速度,在轻量的同时有对 Vim 和 Neovim 不同版本提供了兼容性支持

Plugins In Neovim

LSP 是一个由 Microsoft 提出的开放式协议,用于在编辑器、IDE 和语言服务器之间进行通信。在 LSP 之前,不同的工具都需要独立实现各自的代码功能。这意味着同一门编程语言在各种工具之间可能会存在不同的效果,且不能够很好的兼容。那么对于同一种编程语言,如果让各种工具都可以共享一个语言服务器,那么就可以实现代码补全、语法检查、悬停提示等功能的一致性。

Language Client 和 Language Server 是 LSP 协议中的两个重要角色:

  • Language Server 作为服务提供者,提供与特定编程语言相关的语言分析和操作服务
  • Language Client 通过实现 LSP 协议,可以向语言服务器发送请求,接收并显示分析的结果

Nvim supports the Language Server Protocol (LSP), which means it acts as a client to LSP servers and includes a Lua framework vim.lsp for building enhanced LSP tools.

Neovim 本身并不是一个 Language Client,尽管其内置了一套 LSP 的实现。因此,若想在 Neovim 中使用 LSP,仍然需要安装一个 LSP 客户端插件来提供 Language Client 的功能。在 Neovim 中,可以通过 :h lsp:h vim.lsp 查看相关文档。本文配置 LSP 使用 nvim-lspconfig 与 mason.nvim

jdtls 全称是 Eclipse JDT Language Server,这是一个基于 Eclipse JDT(Java Development Tools)构建的 LSP(Language Server Protocol)实现,用于提供对 Java 语言的代码分析和操作支持,可以在各种编辑器和 IDE 中使用,如 Eclipse、Visual Studio Code、Sublime Text 等。

The Transparency Scheme

vim ~/.vim_runtime/sources_non_forked/vim-colors-solarized/colors/solarized.vim
" Customize and Modify color
" let s:base01      = "10"
let s:base01      = "7"
" let s:base00      = "11"
let s:base00      = "7"

使用 amix/vimrc 后,额外的配置应添加在 ~/.vim_runtime/my_configs.vim 文件。

" --- Custom config ---
" 关闭与 Vi 兼容的模式
set nocompatible
" 设置文本编码为 UTF-8
set encoding=utf-8
" 显示行号,同时也显示相对行号
set nu
set relativenumber
" 设置文件名自动补全模式
set wildmode=longest,list,full
" 开启命令行自动补全菜单
set wildmenu
" 开启鼠标支持,可以使用鼠标操作 Vim
set mouse=a

" 定义一个函数 SetTabIndent() 用于设置缩进
function SetTabIndent()
  " 根据文件类型设置缩进
  if &ft == "javascript" || &ft == "typescript" || &ft == "json" || &ft == "vue" || &ft == "jsx" || &ft == "lua" || &ft == "yaml" || &ft == "css" || &ft == "scss" || &ft == "css" || &ft == "xml" || &ft == "vim"
    setlocal tabstop=2 softtabstop=2 shiftwidth=2 expandtab
  elseif &ft == "java" || &ft == "python"
    setlocal tabstop=4 softtabstop=4 shiftwidth=4 expandtab
  else
    setlocal tabstop=8 softtabstop=0 shiftwidth=8 noexpandtab
  endif
endfunction

" 在 BufRead 和 BufNewFile 事件中调用 SetTabIndent() 函数
augroup TabIndent
  autocmd!
  autocmd BufRead,BufNewFile * call SetTabIndent()
augroup END

syntax enable " 开启语法高亮

" 当前使用 amix/vimrc,将 vim 打开的文件背景设置为与 iterm2 一致(transparency 25)
if has("gui_running")
  " GUI 相关设置
  " etc...
else
  " 终端相关设置
  set t_Co=256
  " 判断当前终端的类型是否包含字符串"256color"
  if &term =~ '256color'
    " 设置终端的背景色和前景色
    " iTerm2 等终端的透明度在终端外面设置即可
    " https://stackoverflow.com/a/34926660/10538725
    set background=dark
    " 加载颜色主题
    colorscheme solarized
    " 设置背景透明度 => 应确保在设置之前有加载颜色主题
    hi Normal ctermbg=none
    " 设置 Pmenu 的透明度
    hi Pmenu ctermbg=none
    hi PmenuSel ctermbg=none
    " 竖线用空格填充
    set fillchars+=vert:\
    set linespace=0
    " 自动补全透明度
    set pumblend=20
    " 窗口透明度
    set winblend=20
  endif
endif

在 nvim 的配置文件 $HOME/.config/nvim/init.lua 中,添加以下代码。

-- 使用 nvim-lua/kickstart,将通过 nvim 打开的文件背景设置为与 iterm2 一致
vim.o.background = 'dark' -- 设置为 dark 背景
vim.o.termguicolors = true -- 启用 24 位色彩
vim.cmd('au VimEnter * hi Normal guibg=none ctermbg=none') -- 设置 Normal 背景为透明
vim.cmd('au VimEnter * hi NonText guibg=none ctermbg=none') -- 设置 NonText 背景为透明
vim.cmd('au VimEnter * hi SignColumn guibg=none ctermbg=none') -- 设置 SignColumn 背景为透明
vim.cmd('au VimEnter * hi LineNr guibg=none ctermbg=none') -- 设置 LineNr 背景为透明
vim.cmd('au VimEnter * hi EndOfBuffer guibg=none ctermbg=none') -- 设置 EndOfBuffer 背景为透明
vim.cmd('au VimEnter * hi VertSplit guifg=NONE guibg=NONE') -- 设置分割线背景为透明
vim.cmd('au VimEnter * hi Folded guibg=none ctermbg=none') -- 设置 Folded 背景为透明

NeoTree 默认使用 netrw.vim 插件来管理文件树。NeoTree 是一个类似于 netrw 的文件浏览器插件。

nvim-neo-tree 配置文件 ~/.config/nvim/lua/custom/plugins/filetree.lua 如下。

-- Unless you are still migrating, remove the deprecated commands from v1.x
vim.cmd([[ let g:neo_tree_remove_legacy_commands = 1 ]])

-- Realize neotree transparency
-- https://www.reddit.com/r/neovim/comments/10vrw9s/making_neotree_transparent_in_lazyvim/
vim.api.nvim_create_augroup("nobg", { clear = true })
vim.api.nvim_create_autocmd({ "ColorScheme" }, {
  desc = "Make all backgrounds transparent",
  group = "nobg",
  pattern = "*",
  callback = function()
    -- 函数 vim.api.nvim_set_hl() 用于设置 Vim 的高亮组
    vim.api.nvim_set_hl(0, "Normal", { bg = "NONE", ctermbg = "NONE" })
    vim.api.nvim_set_hl(0, "NeoTreeNormal", { bg = "NONE", ctermbg = "NONE" })
    vim.api.nvim_set_hl(0, "NeoTreeNormalNC", { bg = "NONE", ctermbg = "NONE" })
    vim.api.nvim_set_hl(0, "NeoTreeEndOfBuffer", { bg = "NONE", ctermbg = "NONE" })
    -- etc...
  end,
})

return {
  "nvim-neo-tree/neo-tree.nvim",
  version = "*",
  dependencies = {
    "nvim-lua/plenary.nvim",
    "nvim-tree/nvim-web-devicons", -- not strictly required, but recommended
    "MunifTanjim/nui.nvim",
  },
  config = function ()
    -- If you want icons for diagnostic errors, you'll need to define them somewhere:
      vim.fn.sign_define("DiagnosticSignError",
        {text = " ", texthl = "DiagnosticSignError"})
      vim.fn.sign_define("DiagnosticSignWarn",
        {text = " ", texthl = "DiagnosticSignWarn"})
      vim.fn.sign_define("DiagnosticSignInfo",
        {text = " ", texthl = "DiagnosticSignInfo"})
      vim.fn.sign_define("DiagnosticSignHint",
        {text = "", texthl = "DiagnosticSignHint"})
      -- NOTE: this is changed from v1.x, which used the old style of highlight groups
      -- in the form "LspDiagnosticsSignWarning"
    require('neo-tree').setup {
      popup_border_style = "rounded",
      enable_git_status = true,
      enable_diagnostics = true,
      open_files_do_not_replace_types = { "terminal", "trouble", "qf" }, -- when opening files, do not use windows containing these filetypes or buftypes
      sort_case_insensitive = false, -- used when sorting files and directories in the tree
      sort_function = nil , -- use a custom function for sorting files and directories in the tree
      default_component_configs = {
          container = {
            enable_character_fade = true
          },
          indent = {
            indent_size = 2,
            padding = 1, -- extra padding on left hand side
            -- indent guides
            with_markers = true,
            indent_marker = "│",
            last_indent_marker = "└",
            highlight = "NeoTreeIndentMarker",
            -- expander config, needed for nesting files
            with_expanders = nil, -- if nil and file nesting is enabled, will enable expanders
            expander_collapsed = "",
            expander_expanded = "",
            expander_highlight = "NeoTreeExpander",
          },
          icon = {
            folder_closed = "",
            folder_open = "",
            folder_empty = "ﰊ",
            -- The next two settings are only a fallback, if you use nvim-web-devicons and configure default icons there
            -- then these will never be used.
            default = "*",
            highlight = "NeoTreeFileIcon"
          },
          modified = {
            symbol = "[+]",
            highlight = "NeoTreeModified",
          },
          name = {
            trailing_slash = false,
            use_git_status_colors = true,
            highlight = "NeoTreeFileName",
          },
          git_status = {
            symbols = {
              -- Change type
              added     = "", -- or "✚", but this is redundant info if you use git_status_colors on the name
              modified  = "", -- or "", but this is redundant info if you use git_status_colors on the name
              deleted   = "✖",-- this can only be used in the git_status source
              renamed   = "",-- this can only be used in the git_status source
              -- Status type
              untracked = "",
              ignored   = "",
              unstaged  = "",
              staged    = "",
              conflict  = "",
            }
          },
        },
      -- neo-tree.nvim/lua/neo-tree/defaults.lua
      -- https://github.com/nvim-neo-tree/neo-tree.nvim/issues/446
      filesystem = {
        filtered_items = {
          visible = true, -- default false 显示隐藏文件
        }
      },
    }
    vim.cmd([[nnoremap \ :Neotree toggle<cr>]])
  end,
}

NeoTree.nvim 中的高亮组 NeoTreeEndOfBuffer、NeoTreeNormalNC、NeoTreeNormal、Normal:

  • NeoTreeEndOfBuffer 表示文件树窗口底部以下的区域,该区域通常会有提示信息
  • NeoTreeNormalNC 表示文件树中没有被选中的文件或目录的名称的颜色,NC 即 No Cursor
  • NeoTreeNormal 表示文件树中被选中的文件或目录的名称的颜色
  • Normal 是 Vim 的一个默认高亮组,表示在普通模式下文本的颜色

这里选择 bufferline.nvim 给 Neovim 增加顶部标签页插件,该插件相应配置如下。

-- bufferline
-- 左右 Tab 切换
vim.api.nvim_set_keymap("n", "<C-h>", ":BufferLineCyclePrev<CR>", {noremap = true, silent = true })
vim.api.nvim_set_keymap("n", "<C-l>", ":BufferLineCycleNext<CR>", {noremap = true, silent = true })
return {
  "akinsho/bufferline.nvim",
  version = "*",
  dependencies = {
    "nvim-tree/nvim-web-devicons", -- not strictly required, but recommended
  },
  config = function ()
    require("bufferline").setup{}
  end,
}

以上就是在配置 Neovim 时所需要注意的一些内容,文末附一张本人的配置效果。撒花 🎉。

Vim & Neovim

Bug Fixes

Exception: jdtls requires at least Java 17

这个异常信息意味着 jdtls 需要至少 Java 17 的版本才能正常运行,当前系统中安装的 Java 版本低于这个要求,需要升级 Java 版本至 17 或更高。

Learn Neovim The Practical Way
All articles on how to configure and program Neovim.
Vim & Neovim
https://alpha2phi.medium.com/learn-neovim-the-practical-way-8818fcf4830f
Vim cheatsheet
One-page guide to Vim: usage, examples, and more. Vim is a very efficient text editor. This reference was made for Vim 8.0. For shortcut notation, see :help key-notation.
Vim & Neovim
https://devhints.io/vim
GitHub - glepnir/nvim-lua-guide-zh: https://github.com/nanotee/nvim-lua-guide chinese version
https://github.com/nanotee/nvim-lua-guide chinese version - GitHub - glepnir/nvim-lua-guide-zh: https://github.com/nanotee/nvim-lua-guide chinese version
Vim & Neovim
https://github.com/glepnir/nvim-lua-guide-zh
Lua - Neovim docs
Neovim user documentation
https://neovim.io/doc/user/lua.html

结束

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议,转载请注明出处!

]]>
<![CDATA[Design Patterns And ...]]>https://zairesinatra.github.io//design-patterns/64059084b7c7da7c2d62d4bfMon, 08 Aug 2022 14:02:00 GMT

Quick Start

SOLID Design Principles

Design Patterns And ...

SOLID is an acronym for the first five object-oriented design (OOD) principles by Robert C. Martin (also known as Uncle Bob).

Single-responsibility Principle (SRP) states: A class should have one and only one reason to change, meaning that a class should have only one job.

单一职责原则:一个类或者模块应该有且只有一个改变的原因。当一个类或者模块承担了过多的职责,那么可能造成其状态不稳定的因素也会随之增多。

Open-closed Principle (OCP) states: Objects or entities should be open for extension but closed for modification.

开放封闭原则:实体(类、模块、函数等)应该保持对扩展的开放,保证对修改的关闭。实体应该通过扩展的方式来增加新的功能,而不是修改已有的代码。

Liskov Substitution Principle states: Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.

里氏替换原则:任何一个子类都应该能够替换其父类的位置,而且不会影响到程序的正确执行。该原则强调子类应该尽量遵守父类的约定和接口。

Interface Segregation Principle (ISP) states: A client should never be forced to implement an interface that it doesn’t use, or clients shouldn’t be forced to depend on methods they do not use.

接口隔离原则:不应该强迫客户端实现其不需要的接口。接口要尽可能的专一和精简,防止出现庞大且复杂情况(避免额外实现了不需要的功能与方法)。

Dependency Inversion Principle (DIP) states: Entities must depend on abstractions, not on concretions. It states that the high-level module must not depend on the low-level module, but they should depend on abstractions.

依赖倒置原则:高层模块不应该依赖低层模块;两者都应该依赖于抽象(接口或者抽象类)。在高层模块依赖于低层模块的情况下,如果低层模块发生变动,那么高层模块中的代码也将要随之改变。抽象不应依赖于细节,细节应该取决于抽象。

GoF Design Pattern Types

设计模式通常涉及对象之间的交互方式,包括创建、管理和修改对象。设计模式不是一种具体的代码实现,而是一种通用的解决方案,可以用于不同编程语言和技术栈中。简单来说,设计模式是一些经过总结、归纳的、被广泛认可的解决开发中常见问题的最佳实践。

In 1994, four authors Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides published a book titled Design Patterns - Elements of Reusable Object-Oriented Software which initiated the concept of Design Pattern in Software development.These authors are collectively known as Gang of Four (GOF).

设计模式分为三种类型:创建型模式、结构型模式和行为型模式。创建型模式关注如何实例化对象,结构型模式关注如何组织代码,行为型模式关注代码之间的交互和职责分配。

Categories Design Patterns
Creational Factory Method、Abstract Factory、Builder、Prototype、Singleton
Structural Adapter、Bridge、Composite、Decorator、Facade、Flyweight、Proxy
Behavioral Chain of Responsibility、Command、Interpreter、Iterator、Mediator、Memento、Observer、State、Strategy、Template Method、Visitor

High Cohesion and Low Coupling

高内聚是指一个组件内部的各个元素相互之间紧密联系,共同完成特定的任务或者实现特定的功能。高内聚的组件应该是一个单一的、有明确职责的模块,组件内部的元素之间应该是高度相关的,相互依赖度较高。

低耦合则是指组件之间的相互依赖性尽可能地降低,即组件之间的接口设计得尽可能简单、明确,组件之间的关系尽可能松散。这样的设计可以使得系统更加容易扩展和修改,也可以减少系统出错的可能性。

ASD & CI/CD

敏捷开发和持续集成、交付都是为了提高开发效率和质量的方法论与实践:

  • 敏捷开发 Agile Software Development
  • 持续集成/交付 Continuous Integration/Delivery

敏捷开发是一种以迭代、循序渐进的方式进行软件开发的方法。敏捷开发的核心理念是快速响应变化,强调团队合作和实践,注重软件的可测试性、可维护性和可扩展性。

持续集成、交付是一种开发方法,它通过不断地自动化构建、测试和部署来提高软件交付的速度和质量。持续集成是指在代码的每次提交后,自动进行构建、单元测试、代码分析等操作,以确保新代码的质量和稳定,减少出现有问题的代码。持续交付是指将已经通过 CI 测试的代码自动部署到生产环境。

Creational Design Patterns

Abstract Factory

The abstract factory pattern provides a way to create families of related objects without imposing their concrete classes, by encapsulating a group of individual factories that have a common theme without specifying their concrete classes.

抽象工厂模式通过引入抽象工厂类和抽象产品类,在工厂模式的基础上进一步的抽象。这两种设计模式的主要区别如下:

  • 工厂模式:只有一个工厂类,该工厂类负责创建一类产品对象
  • 抽象工厂模式:多个抽象的工厂类和产品类,还有多个产品需要创建

抽象工厂模式常见的应用场景:

  • Spring 通过 BeanFactory 和 ApplicationContext 接口创建和管理 Bean
  • 通过 React.createElement 和 ReactDOM.render 创建和渲染不同的组件
// 抽象产品类
interface Button {
    void click();
}

// 具体产品类
class WindowsButton implements Button {
    @Override
    public void click() {
        System.out.println("Windows button clicked.");
    }
}

// 具体产品类
class MacButton implements Button {
    @Override
    public void click() {
        System.out.println("Mac button clicked.");
    }
}

// 抽象工厂类
interface GUIFactory {
    Button createButton();
}

// 具体工厂类
class WindowsFactory implements GUIFactory {
    @Override
    public Button createButton() {
        return new WindowsButton();
    }
}

// 具体工厂类
class MacFactory implements GUIFactory {
    @Override
    public Button createButton() {
        return new MacButton();
    }
}

// 测试代码
public class Test {
    public static void main(String[] args) {
        // 创建 Windows 工厂
        GUIFactory windowsFactory = new WindowsFactory();
        // 创建 Windows 按钮
        Button windowsButton = windowsFactory.createButton();
        // 调用按钮的 click 方法
        windowsButton.click();

        // 创建 Mac 工厂
        GUIFactory macFactory = new MacFactory();
        // 创建 Mac 按钮
        Button macButton = macFactory.createButton();
        // 调用按钮的 click 方法
        macButton.click();
    }
}

Builder Pattern

Separate the construction of a complex object from its representation so that the same construction process can create different representations.

生成器模式的核心是将复杂对象的构建过程分解为多个简单对象的构建过程,然后逐步组合这些简单对象,最终构建出完整的复杂对象。该模式通常包含一个生成器接口和具体的生成器类,生成器类负责构建复杂对象的各个部分。

生成器模式常见的应用场景:

  • JdbcTemplate 类使用生成器模式来构建和配置 PreparedStatement 对象
  • Spring Data JPA 创建和配置各种实体类、仓库接口以及查询对象
// 产品类
class Product {
    private String partA;
    private String partB;
    private String partC;

    public void setPartA(String partA) {
        this.partA = partA;
    }

    public void setPartB(String partB) {
        this.partB = partB;
    }

    public void setPartC(String partC) {
        this.partC = partC;
    }

    public String toString() {
        return "PartA=" + partA + ", PartB=" + partB + ", PartC=" + partC;
    }
}

// 生成器接口
interface Builder {
    void buildPartA();
    void buildPartB();
    void buildPartC();
    Product getResult();
}

// 具体生成器类
class ConcreteBuilder implements Builder {
    private Product product = new Product();

    @Override
    public void buildPartA() {
        product.setPartA("PartA");
    }

    @Override
    public void buildPartB() {
        product.setPartB("PartB");
    }

    @Override
    public void buildPartC() {
        product.setPartC("PartC");
    }

    @Override
    public Product getResult() {
        return product;
    }
}

// 指挥者类
class Director {
    private Builder builder;

    public Director(Builder builder) {
        this.builder = builder;
    }

    public void construct() {
        builder.buildPartA();
        builder.buildPartB();
        builder.buildPartC();
    }
}

// 测试代码
public class Test {
    public static void main(String[] args) {
        // 创建具体生成器
        Builder builder = new ConcreteBuilder();
        // 创建指挥者
        Director director = new Director(builder);
        // 构建产品
        director.construct();
        // 获取构建完成的产品对象
        Product product = builder.getResult();
        // 输出产品信息
        System.out.println(product.toString());
    }
}

Factory Method

工厂模式可以封装对象的创建逻辑,使得创建过程更加灵活。工厂模式是通过工厂方法来创建对象,而不是在代码中直接使用 new 关键字来创建对象。

A Factory Pattern or Factory Method Pattern says that just define an interface or abstract class for creating an object but let the subclasses decide which class to instantiate. In other words, subclasses are responsible to create the instance of the class.

工厂模式常见的应用场景:

  • 对象的创建过程比较复杂,需要进行初始化和配置
  • 对象的创建过程需要进行封装,避免使用者直接访问对象的实现细节
  • 需要根据不同的条件创建不同的对象实例

在 DOM 中,document.createElement() 方法就是一个工厂方法,通过传入一个标签名来创建对应的元素节点对象。

// 定义一个产品类
class Product {
  constructor(name, price) {
    this.name = name;
    this.price = price;
  }

  getDescription() {
    return `${this.name} - $${this.price}`;
  }
}

// 定义一个工厂类
class ProductFactory {
  createProduct(name, price) {
    return new Product(name, price);
  }
}

// 创建一个工厂实例
const factory = new ProductFactory();

// 使用工厂方法创建产品对象
const product1 = factory.createProduct('Product 1', 10);
const product2 = factory.createProduct('Product 2', 20);

// 输出产品对象的描述信息
console.log(product1.getDescription());
console.log(product2.getDescription());

Prototype Pattern

The Prototype Pattern is a creational design pattern in software development that allows us to create new objects by cloning an existing object, called the prototype, rather than creating them from scratch.

原型模式的主要目的是通过克隆现有对象来创建新对象,而无需显式地指定所需的类。原型应该提供一个克隆的方法,该方法创建并返回一个与原型相同的副本。

原型模式的主要优点是可以动态地添加或者删除原型,此外,由于不需要显式地指定所需的类,因此可以减少代码中的耦合度。

在 JavaScript 中,可以使用内置的 Object.create() 方法来实现原型模式。

The Object.create() static method creates a new object, using an existing object as the prototype of the newly created object.

// Define the prototype object
const coffeePrototype = {
  type: 'coffee',
  temperature: 'iced',
  name: 'Americano',
  milk: false,
  sugar: false,
  getDescription() {
    let description = `${this.temperature} ${this.name}`;
    if (this.milk) {
      description += ' with milk';
    }
    if (this.sugar) {
      description += ' with sugar';
    }
    return description;
  }
};

// Create a new object by cloning the prototype object
const icedAmericano = Object.create(coffeePrototype);

// Customize the new object
icedAmericano.temperature = 'iced';
icedAmericano.milk = false;
icedAmericano.sugar = true;

// Use the new object
console.log(icedAmericano.getDescription()); // Outputs "iced Americano with sugar"

A marker interface is an interface that doesn't have any methods or constants inside it. It provides run-time type information about objects, so the compiler and JVM have additional information about the object.

如果一个类实现了标记接口 Cloneable,那么该类的实例可以通过调用 Object 类中的 clone() 方法来创建一个该类实例的副本。

然而 Cloneable 接口并不会强制要求类提供 clone() 方法,只是表示这个类允许复制。为避免抛出 CloneNotSupportedException 异常,需实现 clone() 方法。

值得注意的是,如果需要实现深拷贝,那么应该在方法的实现中对每一个引用类型的属性进行递归复制。因为在 Java 中 clone() 方法是浅拷贝。

public abstract class Coffee implements Cloneable {
    protected String type;
    protected double price;

    public abstract void setType(String type);
    public abstract String getType();
    public abstract void setPrice(double price);
    public abstract double getPrice();

    public Object clone() {
        Object clone = null;

        try {
            clone = super.clone();

        } catch (CloneNotSupportedException e) {
            e.printStackTrace();
        }

        return clone;
    }
}

public class IcedAmericano extends Coffee {

    public IcedAmericano() {
        type = "Iced Americano";
        price = 3.5;
    }

    @Override
    public void setType(String type) {
        this.type = type;
    }

    @Override
    public String getType() {
        return type;
    }

    @Override
    public void setPrice(double price) {
        this.price = price;
    }

    @Override
    public double getPrice() {
        return price;
    }
}

public class PrototypeDemo {
    public static void main(String[] args) {
        IcedAmericano icedAmericano = new IcedAmericano();

        IcedAmericano clonedIcedAmericano = (IcedAmericano) icedAmericano.clone();

        System.out.println(clonedIcedAmericano.getType());
        System.out.println(clonedIcedAmericano.getPrice());
    }
}

Singleton Pattern

Implementations of the singleton pattern ensure that only one instance of the singleton class ever exists and typically provide global access to that instance.

单例模式能够保证在整个应用程序中,某个类只有一个实例对象的存在,并提供一个访问该实例的全局访问点。单例模式通常用于管理全局变量、模块管理以及资源共享等场景。

Vue 中 Vuex 和 Vue Router 以及 React 中 React Router 和 Redux 都是单例模式。

class Singleton {
  static #instance; // 私有静态属性,用于存储单例实例

  constructor() {
    if (Singleton.#instance) {
      return Singleton.#instance;
    }
    Singleton.#instance = this;
  }

  static getInstance() {
    if (!Singleton.#instance) {
      Singleton.#instance = new Singleton();
    }
    return Singleton.#instance;
  }
}

// 调用单例模式创建对象实例
const instance1 = Singleton.getInstance();
const instance2 = Singleton.getInstance();

console.log(instance1 === instance2); // true

Structural Design Patterns

Adapter Pattern

适配器模式可以将不兼容的接口转换为兼容的接口,从而使不同接口之间可以协同工作。适配器模式可以提高代码的复用性、可扩展性和可维护性,从而降低系统的复杂度和耦合度。

适配器模式常见的应用场景如下:

  • 将不同版本的 API 转换为统一的接口
  • 将第三方库的接口转换为内部系统的接口
  • 将不同的数据源(如数据库、文件、网络等)转换为统一的数据模型
// 定义不兼容的接口
const legacyApi = {
  getFullName: function(firstName, lastName) {
    return firstName + ' ' + lastName;
  }
};

// 定义兼容的接口
const newApi = {
  getName: function(name) {
    const [firstName, lastName] = name.split(' ');
    return {
      firstName,
      lastName
    };
  }
};

// 定义适配器
const adapter = {
  getName: function(name) {
    return legacyApi.getFullName(...name.split(' '));
  }
};

// 使用示例
const user = adapter.getName('John Smith');
console.log(user);  // 输出 "John Smith"

在上面的示例中,通过适配器对象来调用不兼容的接口,并将其转换为所需要的格式(产生和使用兼容接口一样的效果)。适配器模式可以在不修改原有代码的情况下,实现不同接口之间的协同工作。

Bridge Pattern

The bridge pattern decouples an abstraction from its implementation so that the two can vary independently.

桥接模式可以将抽象与实现做解耦,让两者可以独立的变化。抽象部分通常会包含所有要实现的方法,而实现部分会选择其中一些方法进行具体的实现。

Bridge 模式在 Java 中的应用场景包括:

  • JDBC 提供了一个标准的接口,而不同类型的数据库驱动提供具体的实现
  • ORM 框架提供了标准的接口,而不同的数据库提供了具体的实现
interface CoffeeAdditive {
    public void addSomething();
}

class Milk implements CoffeeAdditive {
    @Override
    public void addSomething() {
        System.out.println("加奶");
    }
}

class Sugar implements CoffeeAdditive {
    @Override
    public void addSomething() {
        System.out.println("加糖");
    }
}

abstract class Coffee {
    protected CoffeeAdditive additive;

    protected Coffee(CoffeeAdditive additive) {
        this.additive = additive;
    }

    public abstract void makeCoffee();
}

class Americano extends Coffee {
    public Americano(CoffeeAdditive additive) {
        super(additive);
    }

    @Override
    public void makeCoffee() {
        System.out.print("冰美式 - ");
        additive.addSomething();
    }
}

class Latte extends Coffee {
    public Latte(CoffeeAdditive additive) {
        super(additive);
    }

    @Override
    public void makeCoffee() {
        System.out.print("冰拿铁 - ");
        additive.addSomething();
    }
}

public class BridgePatternTest {
    public static void main(String[] args) {
        CoffeeAdditive milk = new Milk();
        CoffeeAdditive sugar = new Sugar();

        Coffee americanoWithMilk = new Americano(milk);
        Coffee americanoWithSugar = new Americano(sugar);
        Coffee latteWithMilk = new Latte(milk);
        Coffee latteWithSugar = new Latte(sugar);

        americanoWithMilk.makeCoffee();
        americanoWithSugar.makeCoffee();
        latteWithMilk.makeCoffee();
        latteWithSugar.makeCoffee();
    }
}

Composite Pattern

The intent of a composite is to compose objects into tree structures to represent part-whole hierarchies.

复合模式描述了一组对象,这些对象被视为同一类型对象的单个实例。组合允许有一个树结构,并要求树结构中的每个节点执行一个任务。

在树结构中需要注意叶子节点和组合节点。叶子节点表示树形结构的末端对象,而组合节点则表示树形结构中的分支。组合节点可以包含叶子节点以及其他的组合节点,从而形成一个树形结构。

组合模式的常见应用场景如下:

  • JavaScript 中的 DOM 树:由一个根节点和其他节点组成
  • Java 中的 GUI 控件库:一个窗口中可能包含有多个组件
// 抽象组件
class Beverage {
  constructor(name, price) {
    this.name = name
    this.price = price
  }
  getPrice() {
    return this.price
  }
  getName() {
    return this.name
  }
}

// 叶子组件
class Coffee extends Beverage {
  constructor(name, price) {
    super(name, price)
  }
}

class Espresso extends Coffee {
  constructor() {
    super('Espresso', 2)
  }
}

class Americano extends Coffee {
  constructor() {
    super('Americano', 2.5)
  }
}

class Latte extends Coffee {
  constructor() {
    super('Latte', 3)
  }
}

// 组合组件
class Menu extends Beverage {
  constructor(name) {
    super(name, 0)
    this.beverages = []
  }
  add(beverage) {
    this.beverages.push(beverage)
  }
  getPrice() {
    let total = 0
    this.beverages.forEach(beverage => {
      total += beverage.getPrice()
    })
    return total
  }
}

// 组合组件
class CoffeeMenu extends Menu {
  constructor() {
    super('Coffee Menu')
    this.add(new Espresso())
    this.add(new Americano())
    this.add(new Latte())
  }
}

// 使用示例
const coffeeMenu = new CoffeeMenu()
console.log(coffeeMenu.getName()) // 输出:Coffee Menu
console.log(coffeeMenu.getPrice()) // 输出:7.5

在上述代码中,Espresso、Americano 和 Latte 都是 Coffee 的子类,可以看作是定义了三个叶子组件。定义的 CoffeeMenu 类作为一个具体的组合组件,继承自抽象类 Menu。

Decorator Pattern

装饰器模式常用于向对象添加新的行为或修改现有的功能,同时避免修改原始对象的结构。

装饰器模式的常见应用场景如下:

  • Web 开发中的中间件:中间件装饰器通常会包装一个处理请求的函数
  • Java I/O 类库中的装饰器:对输入和输出流进行功能扩展
  • GUI 应用程序中的 UI 组件:使用装饰器来添加拖放、缩放和旋转功能等
// 带有缓冲区和压缩功能的输入流 in
InputStream in = new FileInputStream("input.txt");
in = new BufferedInputStream(in);
in = new GZIPInputStream(in);
JButton button = new JButton("Click me");
button = new DraggableDecorator(button);
button = new ResizableDecorator(button);
button = new RotatableDecorator(button);
class Coffee {
  getCost() {
    return 2;
  }
}

class CoffeeDecorator {
  constructor(coffee) {
    this.coffee = coffee;
  }

  getCost() {
    return this.coffee.getCost();
  }
}

class SugarDecorator extends CoffeeDecorator {
  getCost() {
    return this.coffee.getCost() + 0.5;
  }
}

class MilkDecorator extends CoffeeDecorator {
  getCost() {
    return this.coffee.getCost() + 1;
  }
}

const coffee = new Coffee();
const coffeeWithSugar = new SugarDecorator(coffee);
const coffeeWithSugarAndMilk = new MilkDecorator(coffeeWithSugar);

console.log(coffeeWithSugarAndMilk.getCost()); // 输出 3.5

Facade pattern

Analogous to a facade in architecture, a facade is an object that serves as a front-facing interface masking more complex underlying or structural code.

外观模式通过提供一个简化的接口,隐藏繁杂的构成细节,让客户端能够更方便地使用复杂系统的功能。

外观模式的常见应用场景如下:

  • 使用 Axios 简化 AJAX 请求过程可以看作是一种外观模式的实现
  • Java Servlet API 中 javax.servlet.ServletContext 接口
  • JDBC DriverManager 类提供了一个简单的接口来获取数据库连接
// 定义一个外观类
public class IcedAmericanoFacade {
    private CoffeeMaker coffeeMaker;
    private IceMaker iceMaker;

    public IcedAmericanoFacade() {
        this.coffeeMaker = new CoffeeMaker();
        this.iceMaker = new IceMaker();
    }

    // 将复杂的操作封装在一个方法中,对外提供简单的接口
    public void makeIcedAmericano() {
        coffeeMaker.brewCoffee();
        iceMaker.crushIce();
        System.out.println("Here is your Iced Americano!");
    }
}

// 定义一些子系统类
class CoffeeMaker {
    public void brewCoffee() {
        System.out.println("Brewing coffee...");
    }
}

class IceMaker {
    public void crushIce() {
        System.out.println("Crushing ice...");
    }
}
public class Client {
    public static void main(String[] args) {
        IcedAmericanoFacade icedAmericano = new IcedAmericanoFacade();
        icedAmericano.makeIcedAmericano();
    }
}

Flyweight Pattern

The Flyweight Design Pattern refers to an object that minimizes memory usage by sharing some of its data with other similar objects.

享元模式旨在通过共享对象来减少内存的使用和对象创建的开销,以提高系统的性能和效率。享元这个词可以理解为共享一些元数据,那么在一定程度上是可以轻量化程序的,这也和 Flyweight 作为拳击术语“轻量级”的概念不谋而合。

享元模式(轻量模式)下会存在两类状态:内部状态和外部状态。内部状态是可以被多个对象所共享的属性,而外部状态是每个对象单独拥有的属性。内部状态被存储在享元对象中,而外部状态则被作为参数传递给享元对象的方法。

享元模式常见应用场景如下:

  • JS 中字符串是不可变的,可以将多个相同的字符串共享在字符串池中
  • 在 Java 中的 Color 和 Font 类都是不可变的对象,且都使用了享元模式
  • Web 开发中常使用 jQuery 的单例模式来减少对 DOM 的访问次数
public interface Flyweight {
    void operation();
}
public class ConcreteFlyweight implements Flyweight {
    private String intrinsicState;

    public ConcreteFlyweight(String intrinsicState) {
        this.intrinsicState = intrinsicState;
    }

    @Override
    public void operation() {
        System.out.println("ConcreteFlyweight: " + intrinsicState);
    }
}
import java.util.HashMap;
import java.util.Map;

public class FlyweightFactory {
    private Map<String, Flyweight> flyweights = new HashMap<>();

    public Flyweight getFlyweight(String key) {
        if (!flyweights.containsKey(key)) {
            flyweights.put(key, new ConcreteFlyweight(key));
        }
        return flyweights.get(key);
    }
}
public class Client {
    public static void main(String[] args) {
        FlyweightFactory factory = new FlyweightFactory();

        Flyweight flyweight1 = factory.getFlyweight("A");
        Flyweight flyweight2 = factory.getFlyweight("B");
        Flyweight flyweight3 = factory.getFlyweight("A");

        flyweight1.operation();
        flyweight2.operation();
        flyweight3.operation();
    }
}

Proxy Pattern

The Proxy Pattern provides the control for accessing the original object.

代理模式可以隐藏原始对象的实现细节,以及控制在原始对象被访问的前后执行一些额外的操作。

代理模式常见的应用场景如下:

  • Spring AOP 是通过 JDK 动态代理和 CGLIB 代理来实现的
  • React HOC 通过代理模式创建新组件,在原有的组件基础上进行了增强
// 定义接口
interface Subject {
    void request();
}

// 定义真实对象
class RealSubject implements Subject {
    @Override
    public void request() {
        System.out.println("RealSubject: Handling request.");
    }
}

// 定义代理对象
class Proxy implements Subject {
    private RealSubject realSubject;

    @Override
    public void request() {
        if (realSubject == null) {
            realSubject = new RealSubject();
        }
        preRequest();
        realSubject.request();
        postRequest();
    }

    private void preRequest() {
        System.out.println("Proxy: Preparing for request.");
    }

    private void postRequest() {
        System.out.println("Proxy: Handling after request.");
    }
}

// 使用示例
public class Main {
    public static void main(String[] args) {
        Proxy proxy = new Proxy();
        proxy.request();
    }
}

在上述示例中,真实对象类与代理对象类都对完成特定功能的接口进行了实现。在使用时,只需要使用代理对象来调用真实对象的方法即可。

代理模式包括静态代理和动态代理两种形式。在静态代理中,代理类在编译时就已经确定;而在动态代理中,代理类是在运行时动态生成的。

动态代理不需要手动编写代理类,而是通过反射机制动态生成。对于一些与业务逻辑无关的功能,比如统计方法调用次数、添加日志等,开发者不需要在每个类中都手动编写,而是可以在代理类中统一实现。

在 Java 中,动态代理可以通过 java.lang.reflect.Proxy 类和 InvocationHandler 接口来实现。当创建一个动态代理对象时,需要指定一个实现了 InvocationHandler 接口的类,该类会在代理对象的方法被调用时被调用。

与静态代理相比,动态代理的优点在于可以减少代码量,提高代码的可重用性和可维护性,但是动态代理的性能相对较差,因为需要使用反射机制来创建代理类。

Behavioral Design Patterns

Chain of Responsibility

责任链模式将请求发送者和接收者解耦,使多个对象都有机会处理请求。在该模式中,通常会创建一个处理器链,每个处理器都可以决定是否处理请求并将请求传递给下一个处理器,直到请求被处理或者处理器链结束。

责任链模式常见的应用场景如下:

  • 在 Node.js 中,HTTP 请求通常需要通过一系列的中间件进行处理
  • Java 中通常会使用职责链模式来处理异常(定义一系列异常处理程序)
public interface Handler {
    void handleRequest(String request);
    void setNextHandler(Handler nextHandler);
}
public class IceHandler implements Handler {
    private Handler nextHandler;

    public void handleRequest(String request) {
        if (request.contains("冰美式")) {
            System.out.println("正在制作冰美式...");
        } else {
            nextHandler.handleRequest(request);
        }
    }

    public void setNextHandler(Handler nextHandler) {
        this.nextHandler = nextHandler;
    }
}

public class EspressoHandler implements Handler {
    private Handler nextHandler;

    public void handleRequest(String request) {
        if (request.contains("美式")) {
            System.out.println("正在制作美式咖啡...");
        } else {
            System.out.println("无法制作这种饮品...");
        }
    }

    public void setNextHandler(Handler nextHandler) {
        this.nextHandler = nextHandler;
    }
}
public class Main {
    public static void main(String[] args) {
        Handler iceHandler = new IceHandler();
        Handler espressoHandler = new EspressoHandler();

        iceHandler.setNextHandler(espressoHandler);

        iceHandler.handleRequest("来一杯冰美式");
        iceHandler.handleRequest("来一杯奶茶");
    }
}

Command Pattern

The Command Pattern encapsulates a request as an object, thereby letting you parameterize other objects with different requests, queue or log requests, and support undoable operations.

命令模式会将请求转换为一个包含请求相关所有信息的独立对象,以此来把发出请求的对象和执行请求的对象进行解耦。

命令模式常见的应用场景如下:

  • Vue 中的指令可以被视为一种命令模式的应用
  • Spring 在处理请求(命令对象)时,会通过 Spring MVC 的控制器来执行
public interface Command {
    void execute();
}
public class IcedAmericanoCommand implements Command {
    public void execute() {
        System.out.println("iced americano");
    }
}
public class Waiter {
    private Command command;
    public void setCommand(Command command) {
        this.command = command;
    }
    public void serve() {
        command.execute();
    }
}
public class Main {
    public static void main(String[] args) {
        Command command = new IcedAmericanoCommand();
        Waiter waiter = new Waiter();
        waiter.setCommand(command);
        waiter.serve();
    }
}

Interpreter Pattern

Interpreter Pattern provides a way to evaluate language grammar or expr.

解释器模式通常用于处理特定的问题,如编写编译器、解释器和正则表达式等。该模式下的核心组件是解释器与上下文。解释器执行语法树中的节点,并将其转换为操作。上下文通过跟踪当前语句的状态,让解释器执行正确的操作。

访问者模式常见的应用场景:

  • 使用 Babel 编译器可以将 JSX 转换为 JavaScript 函数调用
  • Java 可以将正则表达式解析为语法树,并通过该树来执行匹配操作
interface Expression {
    boolean interpret(String context);
}
class IcedAmericanoExpression implements Expression {
    @Override
    public boolean interpret(String context) {
        if (context.contains("iced americano")) {
            return true;
        } else {
            return false;
        }
    }
}
class Context {
    private String input;

    public Context(String input) {
        this.input = input;
    }

    public boolean getResult(Expression expression) {
        return expression.interpret(input);
    }
}
public class InterpreterPatternDemo {
    public static void main(String[] args) {
        String command = "I would like to have an iced americano, please.";

        Context context = new Context(command);

        Expression expression = new IcedAmericanoExpression();

        if (context.getResult(expression)) {
            System.out.println("Order: Iced Americano");
        } else {
            System.out.println("Invalid order");
        }
    }
}

Iterator Pattern

The essence of the Iterator Pattern is to "Provide a way to access the elements of an aggregate object sequentially without exposing its underlying representation (list, stack, tree, etc.).

简单来说,迭代器模式就是用来遍历集合中元素的一种模式。只需提供一个统一的接口,就可以遍历对象中的元素,而无需知道具体对象的内部实现。

迭代器模式常见的应用场景:

  • ES6 中引入的迭代器和生成器的语法,可以方便地实现迭代器模式
  • lodash 库中有很多与迭代器模式相关的函数,例如 _.each_.map
  • 经 JDBC 连接数据库时,可以通过 ResultSet 遍历查询结果集中的行和列
  • Spring 中用 JdbcTemplate 执行查询语句时,可传入 RowMapper 对象
import java.util.Iterator;

public class CoffeeShop implements Iterable<String> {
    private String[] coffees = {"冰美式", "拿铁", "卡布奇诺", "摩卡"};

    @Override
    public Iterator<String> iterator() {
        return new CoffeeIterator(coffees);
    }
    
    private class CoffeeIterator implements Iterator<String> {
        private int index;
        private String[] coffees;
        
        public CoffeeIterator(String[] coffees) {
            this.index = 0;
            this.coffees = coffees;
        }

        @Override
        public boolean hasNext() {
            return index < coffees.length;
        }

        @Override
        public String next() {
            return coffees[index++];
        }
    }
}
public class Main {
    public static void main(String[] args) {
        CoffeeShop coffeeShop = new CoffeeShop();
        for (String coffee : coffeeShop) {
            System.out.println(coffee);
        }
    }
}

Mediator Pattern

Mediator pattern is used to reduce communication complexity between multiple objects or classes. This pattern provides a mediator class which normally handles all the communications between different classes and supports easy maintenance of the code by loose coupling. Mediator pattern falls under behavioral pattern category.

在中介者模式中,每个对象之间不再直接的进行通信,而是要经过中介者对象来进行合作。中介者对象负责维护对象之间的关系和通信,当某个对象发生变化时,中介者对象会通知其他对象进行相应的处理。

中介者模式常见的应用场景:

  • 当 React 中组件层级变得复杂时,通常会使用 Redux 替代 props 传递数据
  • 在 Spring 中的 BeanFactory 对象负责管理 Spring 中的所有 Bean 对象
public interface CoffeeMediator {
    void makeCoffee(String coffeeType);
}
public class CoffeeMachine {
    private CoffeeMediator mediator;

    public void setMediator(CoffeeMediator mediator) {
        this.mediator = mediator;
    }

    public void makeCoffee(String coffeeType) {
        System.out.println("Making " + coffeeType + " coffee...");
        mediator.makeCoffee(coffeeType);
    }

    public void serveCoffee(String coffeeType) {
        System.out.println("Serving " + coffeeType + " coffee...");
    }
}
public class CoffeeMediatorImpl implements CoffeeMediator {
    private CoffeeMachine coffeeMachine;

    public void setCoffeeMachine(CoffeeMachine coffeeMachine) {
        this.coffeeMachine = coffeeMachine;
    }

    public void makeCoffee(String coffeeType) {
        if (coffeeType.equals("冰美式")) {
            System.out.println("Adding ice to coffee...");
        }
        coffeeMachine.serveCoffee(coffeeType);
    }
}
public class MediatorPatternDemo {
    public static void main(String[] args) {
        CoffeeMediator mediator = new CoffeeMediatorImpl();

        CoffeeMachine coffeeMachine = new CoffeeMachine();
        coffeeMachine.setMediator(mediator);

        CoffeeMediatorImpl mediatorImpl = new CoffeeMediatorImpl();
        mediatorImpl.setCoffeeMachine(coffeeMachine);
        
        coffeeMachine.setMediator(mediatorImpl);

        coffeeMachine.makeCoffee("拿铁");
        coffeeMachine.makeCoffee("冰美式");
    }
}

Memento Pattern

备忘录模式就是在不破坏封装性的前提下,捕获和保存对象的内部状态,以便在需要时可以恢复到先前的状态。

在 Memento Pattern 中,有三个主要的角色:

  • Originator 发起人:创建和恢复 Memento 对象,以及管理自己的状态
  • Memento 备忘录:保存 Originator 对象的状态
  • Caretaker 管理者:负责保存和恢复 Memento 对象,但不知道其内部结构

备忘录模式常见的应用场景:

  • JS 中的 JSON.stringify 和 JSON.parse,以及 Java 中的 Serializable 接口
  • 通过 Java.util.Date 中的 clone() 方法可以创建和保存 Date 对象的副本
  • VPS 和一些数据库(比如 Redis)中的快照功能
import java.io.*;

// 备忘录类,实现 Serializable 接口
class Memento implements Serializable {
    private String state;

    public Memento(String state) {
        this.state = state;
    }

    public String getState() {
        return state;
    }
}

// 原发器类
class CoffeeMachine implements Serializable {
    private String state;

    public void setState(String state) {
        this.state = state;
    }

    public Memento saveState() {
        return new Memento(state);
    }

    public void restoreState(Memento memento) {
        state = memento.getState();
    }

    public void serveCoffee() {
        System.out.println("Serving " + state + " coffee...");
    }
}

// 负责人类
class Caretaker implements Serializable {
    private Memento memento;

    public void saveMemento(Memento m) {
        memento = m;
    }

    public Memento retrieveMemento() {
        return memento;
    }
}

// 客户端代码
public class Main {
    public static void main(String[] args) throws Exception {
        CoffeeMachine coffeeMachine = new CoffeeMachine();
        Caretaker caretaker = new Caretaker();

        coffeeMachine.setState("iced americano");
        coffeeMachine.serveCoffee();
        caretaker.saveMemento(coffeeMachine.saveState());

        coffeeMachine.setState("cappuccino");
        coffeeMachine.serveCoffee();

        coffeeMachine.restoreState(caretaker.retrieveMemento());
        coffeeMachine.serveCoffee();
    }
}

在上述代码中,CoffeeMachine 是原发器类,用于保存状态(即咖啡种类),并提供了保存状态和恢复状态的方法。Memento 类是备忘录类,用于保存原发器的状态。Caretaker 类是负责人,负责保存备忘录,可以根据需要对备忘录进行保存和恢复操作。

Observer Pattern

在某些情况下,观察者模式和发布订阅模式可以看作是相同的模式,因为它们都涉及到对象间的一对多关系。但是在发布订阅模式中,发布者和订阅者之间没有直接的关联,它们通过一个名为消息队列的中介来进行通信。

在观察者模式中,主题(或目标对象)维护了一个观察者列表,并在状态发生变化时通知所有的观察者。在这种模式下,主题和观察者是基类,主题提供维护观察者的一系列方法,观察者提供得到通知后的更新接口。具体主题和具体观察者会继承各自的基类,当具体主题有发生变化时,会调度观察者的更新方法。

综上,观察者模式可以看作是发布订阅模式的一个特例,因为观察者模式中的主题对象本质上就是一个消息队列,而观察者对象则相当于订阅者。

观察者模式的场景应用场景如下:

  • 在 MVC 模式中,模型是主题,视图是观察者。模型变化会使视图更新
  • 在 GUI 编程中,事件是主题,事件处理器是观察者对象
  • 在数据库连接池中,连接池是主题,客户端是观察者对象
// 定义 Subject 类
class Subject {
  constructor() {
    this.observers = [];
  }

  attach(observer) {
    this.observers.push(observer);
  }

  detach(observer) {
    const index = this.observers.indexOf(observer);
    if (index !== -1) {
      this.observers.splice(index, 1);
    }
  }

  notify() {
    for (let observer of this.observers) {
      observer.update();
    }
  }
}

// 定义 Observer 类
class Observer {
  constructor(name, subject) {
    this.name = name;
    this.subject = subject;
    this.subject.attach(this);
  }

  update() {
    console.log(`${this.name} received an update from ${this.subject.constructor.name}.`);
  }

  unsubscribe() {
    this.subject.detach(this);
    console.log(`${this.name} unsubscribed from ${this.subject.constructor.name}.`);
  }
}

// 使用示例
const subject = new Subject();
const observer1 = new Observer("Observer 1", subject);
const observer2 = new Observer("Observer 2", subject);

subject.notify(); // Observer 1 received an update from Subject.
                  // Observer 2 received an update from Subject.

observer1.unsubscribe(); // Observer 1 unsubscribed from Subject.

subject.notify(); // Observer 2 received an update from Subject.

State Pattern

The State Pattern allows an object to alter its behavior when its internal state changes.

状态模式基于有限状态机理论,状态机表示对象在不同状态下的行为,从而使得对象的行为根据状态的改变而改变,这种模式可以避免使用大量的条件判断语句。状态模式具有 Context(上下文)、State(状态)和 ConcreteState(具体状态)。

状态模式常见的应用场景:

  • React 中的组件状态决定了组件的行为,组件行为根据状态的改变而改变
  • Redux 中 store 的状态决定了应用的行为,其行为根据状态的改变而改变
  • Spring State Machine 将状态机的定义和执行分离,实现松耦合状态转换
interface CoffeeState {
    void takeOrder();

    void serveCoffee();
}

class CoffeeContext implements CoffeeState {
    private CoffeeState currentState;

    public CoffeeContext() {
        currentState = new WaitingForOrderState(this);
    }

    public void setState(CoffeeState state) {
        this.currentState = state;
    }

    @Override
    public void takeOrder() {
        currentState.takeOrder();
    }

    @Override
    public void serveCoffee() {
        currentState.serveCoffee();
    }
}

class WaitingForOrderState implements CoffeeState {
    private CoffeeContext context;

    public WaitingForOrderState(CoffeeContext context) {
        this.context = context;
    }

    @Override
    public void takeOrder() {
        System.out.println("Taking order for Iced Americano...");
        context.setState(new ServingCoffeeState(context));
    }

    @Override
    public void serveCoffee() {
        // Do nothing
    }
}

class ServingCoffeeState implements CoffeeState {
    private CoffeeContext context;

    public ServingCoffeeState(CoffeeContext context) {
        this.context = context;
    }

    @Override
    public void takeOrder() {
        // Do nothing
    }

    @Override
    public void serveCoffee() {
        System.out.println("Serving Iced Americano...");
        context.setState(new WaitingForOrderState(context));
    }
}

public class Main {
    public static void main(String[] args) {
        CoffeeContext context = new CoffeeContext();
        context.takeOrder();
        context.serveCoffee();
    }
}

Strategy Pattern

策略模式作为行为模式的一种,可以理解为:根据运行时所触发的方式,动态地选择不同算法的行为。开发中将处理不同任务的算法抽取到一组独立类中,再根据上下文来委派合适的算法就是一种典型的策略。

策略模式常见的应用场景如下:

  • 根据不同的环境或者输入来采取不同的行为或者算法
  • 将一组相关的算法进行封装,以便复用和管理

日志库 Winston 就通过策略模式来实现多个传输器 transports 的切换和组合。

// 定义策略对象
const strategies = {
  'add': function(num1, num2) {
    return num1 + num2;
  },
  'subtract': function(num1, num2) {
    return num1 - num2;
  },
  'multiply': function(num1, num2) {
    return num1 * num2;
  },
  'divide': function(num1, num2) {
    return num1 / num2;
  }
};

// 定义上下文对象
const Calculator = function() {
  this.calculate = function(num1, num2, operation) {
    if (strategies[operation]) {
      return strategies[operation](num1, num2);
    }
  }
}

// 使用示例
const calculator = new Calculator();
console.log(calculator.calculate(2, 3, 'add'));       // 输出 5
console.log(calculator.calculate(6, 3, 'subtract'));  // 输出 3
console.log(calculator.calculate(4, 5, 'multiply'));  // 输出 20
console.log(calculator.calculate(8, 4, 'divide'));    // 输出 2

Template Method

Template method pattern is to define an algorithm as a skeleton of operations and leave the details to be implemented by the child classes.

在模板方法模式中会有一个包含算法骨架(模板方法)的抽象基类(模板类),该模板方法定义了算法的步骤及其顺序。同时,该抽象类中还可以包含一些基本的操作,这些操作可以是具体的实现,也可以是抽象方法,由子类去实现。在子类中实现基本操作的方式是通过继承来实现的,而在模板方法中调用这些基本操作。

模板方法模式常见的应用场景:

  • React 中 componentDidMount()componentDidUpdate() 方法
  • JdbcTemplate 类定义的模板方法 execute() 可以被子类进行实现
public abstract class JdbcOperationTemplate {
    
    protected abstract void setSqlParams(PreparedStatement ps) throws SQLException;
    
    protected abstract void handleResultSet(ResultSet rs) throws SQLException;

    public void execute(String sql, JdbcTemplate jdbcTemplate) {
        jdbcTemplate.execute(conn -> {
            try (PreparedStatement ps = conn.prepareStatement(sql)) {
                setSqlParams(ps);
                ResultSet rs = ps.executeQuery();
                handleResultSet(rs);
                return null;
            }
        });
    }
}

public class FindCoffeeByIdTemplate extends JdbcOperationTemplate {
    
    private final Long coffeeId;
    private Coffee coffee;

    public FindCoffeeByIdTemplate(Long coffeeId) {
        this.coffeeId = coffeeId;
    }

    @Override
    protected void setSqlParams(PreparedStatement ps) throws SQLException {
        ps.setLong(1, coffeeId);
    }

    @Override
    protected void handleResultSet(ResultSet rs) throws SQLException {
        if (rs.next()) {
            coffee = new Coffee();
            coffee.setId(rs.getLong("id"));
            coffee.setName(rs.getString("name"));
            coffee.setPrice(rs.getDouble("price"));
            coffee.setDescription(rs.getString("description"));
            coffee.setCreateTime(rs.getTimestamp("create_time"));
            coffee.setUpdateTime(rs.getTimestamp("update_time"));
        }
    }

    public Coffee getCoffee() {
        return coffee;
    }
}

Visitor Pattern

The Visitor Pattern encapsulates an operation executed on an object hierarchy as an object and enables it to define new operations without changing the object hierarchy.

访问者模式可以在不修改现有对象结构的基础上额外定义一些操作。实际上,就是将操作从对象(元素)中提取出来,并将之存放于另一个独立的对象(访问者)当中。访问者可以访问不同类型的对象,并在这些对象上执行相应的操作。

访问者模式中的两个思想:

  • 被 visitor 所实现的 visit() 方法可以在每一个 element 中被调用
  • 可被访问的类应该提供接收一个 visitor 的 accept() 方法

访问者模式常见的应用场景:

  • Spring 中的 AOP 模块通过 Visitor 模式访问目标对象并进行拦截和增强
  • React 在创建虚拟 DOM 时传入的对象信息可以使用访问者对象进行访问
// Element 接口,定义了 accept 方法
interface Coffee {
    void accept(CoffeeVisitor visitor);
}

// 具体 Element 实现类
class IcedAmericano implements Coffee {
    @Override
    public void accept(CoffeeVisitor visitor) {
        visitor.visit(this);
    }

    public String serve() {
        return "冰美式";
    }
}

class CoconutLatte implements Coffee {
    @Override
    public void accept(CoffeeVisitor visitor) {
        visitor.visit(this);
    }

    public String serve() {
        return "生椰拿铁";
    }
}

// Visitor 接口,定义了 visit 方法
interface CoffeeVisitor {
    void visit(IcedAmericano icedAmericano);
    void visit(CoconutLatte coconutLatte);
}

// 具体 Visitor 实现类
class ConcreteCoffeeVisitor implements CoffeeVisitor {
    @Override
    public void visit(IcedAmericano icedAmericano) {
        System.out.println(icedAmericano.serve());
    }

    @Override
    public void visit(CoconutLatte coconutLatte) {
        System.out.println(coconutLatte.serve());
    }
}

// 客户端
public class Main {
    public static void main(String[] args) {
        Coffee ia = new IcedAmericano();
        Coffee cl = new CoconutLatte();

        CoffeeVisitor coffeeVisitor = new ConcreteCoffeeVisitor();

        ia.accept(coffeeVisitor);
        cl.accept(coffeeVisitor);
    }
}

Additional Design Patterns

Publish/Subscribe

发布订阅模式用于实现对象间的松散耦合和消息通信。发布者将消息或事件发布到一个中心化的消息队列或事件池中,订阅者则从中心化的消息队列或事件池中订阅感兴趣的消息或事件,并在接收到消息或事件时做出相应的响应。

常见的应用场景包括:

  • 在多页面应用中使用发布订阅模式来实现页面间的消息传递和事件触发
  • 模块间通信和 GUI 应用的开发
  • 通过发布订阅模式来实现回调函数的管理和调用

发布订阅模式的实现思路:定义一个具有订阅和发布事件函数的事件中心类。当订阅者订阅一个事件时,要将订阅者的回调函数添加到对应的事件列表中。当发布者发布一个事件时,应该要将对应事件列表中的所有回调函数执行,并将事件数据作为参数进行传递。

// 定义一个事件中心
const eventCenter = {
  // 存储所有订阅者
  subscribers: {},

  // 订阅事件
  subscribe(event, callback) {
    if (!this.subscribers[event]) {
      this.subscribers[event] = [];
    }
    this.subscribers[event].push(callback);
  },

  // 发布事件
  publish(event, data) {
    if (!this.subscribers[event]) {
      return;
    }
    this.subscribers[event].forEach((callback) => {
      callback(data);
    });
  },
};

// 订阅事件
eventCenter.subscribe('event1', (data) => {
  console.log(`Subscriber1 received data: ${data}`);
});
eventCenter.subscribe('event2', (data) => {
  console.log(`Subscriber2 received data: ${data}`);
});

// 发布事件
eventCenter.publish('event1', 'Hello world!');
eventCenter.publish('event2', { foo: 'bar' });

结束

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议,转载请注明出处!

]]>
<![CDATA[G&TOC&CSS]]>https://zairesinatra.github.io//g-toc-css/626d3213eb1f7c0845645cdfSun, 01 May 2022 13:36:00 GMT

GHOST 并没有内置 Table Of Content 的功能,且官方提供的解决方案存在不合理处,遂自行调整部分效果,本文意在记录总结实现过程。

初步实现

按照官方文档,根据文章内容中的标题生成目录需采用一款名为 Tocbot 的小型库。

将获取到的 Tocbot CSS 链接添加到 Casper 主题 default.hbs 文件中 <head> 标签体尾部。

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.18.2/tocbot.css">

同样地操作 Tocbot JavaScript 链接至 default.hbs</body> 标签之前。值得一提的是,随着版本更新,内容选择器不再是官方所绑定的 .post-content,而是根据源码得到的 .gh-content

<script src="https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.18.2/tocbot.min.js"></script>
<script>
  tocbot.init({
    tocSelector: '.toc',
    contentSelector: '.gh-content',
    hasInnerContainers: true
  });
</script>

对于选择区域进行渲染,期望是目录出现在文章内容之前,因此在 .post.hbs 文件完成如下更新。

<section class="gh-content gh-canvas">
  <aside class="toc-container">
    <div class="toc"></div>
  </aside>
  {{content}}
</section>

定制效果

期望能在浏览文章的同时,在侧边看见即时的 TOC 面板,故增加 TOC 随页面滚动更改位置的需求。

对于其他理论可行的方案来说,都或多或少与当前版本源码存在差异,最典型的是布局方式。众所周知,Ghost 后台开启服务的方式有很多种,像是 PM2、云代理,更有甚者直接用 Node 写了一个与 Ghost 模样一致的替代版

function tocHandle() {
  var tc = document.querySelector(".toc-container");
  var ah = document.querySelector(".article-header");
  
  if (tc && ah) {
    var tcch = tc.clientHeight;
    var ahch = ah.clientHeight;
    var isTocSticky = false;

    function handleScroll() {
      if (document.body.clientWidth > 1170) {
        var scrollY = window.pageYOffset || document.documentElement.scrollTop;
        var wih = window.innerHeight;

        if (scrollY >= wih + tcch + ahch && !isTocSticky) {
          tc.style.position = "sticky";
          tc.style.position = "-webkit-sticky";
          tc.style.top = "120px";
          tc.style.marginLeft = "800px";
          tc.style.minWidth = "260px";
          isTocSticky = true;
        }
        
        if (scrollY < tcch + ahch - 10 && isTocSticky) {
          tc.style.position = "";
          tc.style.top = "";
          tc.style.marginLeft = "";
          isTocSticky = false;
        }
      }
    }

    // 避免在每个滚动事件触发时都执行回调函数
    window.addEventListener("scroll", function () {
      requestAnimationFrame(handleScroll);
    });
  }
}

window.addEventListener("DOMContentLoaded", tocHandle);

去除下划线样式并解决类名为 toc-list-item 的 li 下首个子元素距顶部位置较窄。

<!-- TOC -->
<style>
.toc > .toc-list li:first-child,
.toc.active > .toc-list li:first-child {
  margin-top: 8px;
}

.toc-list a,
.toc.active .toc-list a {
  color: #000000 !important;
  text-decoration: none;
  word-break: break-word;
}

.toc > ol,
.toc > li,
.toc.active > ol,
.toc.active > li {
  font-size: 1.4rem;
}
</style>

总结反思

Ghost 现阶段源码的布局是使用 Grid 与 Flex 交叉的方式。若在改变 TOC 元素位置时,将其原有位置在 DOM 上移除,则会导致重排效果,体验感大大降低且消耗性能。鉴于页面可视区的滚动,使用者也不会关注到留白的移动区,故不考虑 DOM 元素的摘除,纯粹临时性改变坐标。

CSS

postion 的选择方式上,比较固定定位 fixed 与粘性定位 sticky。二者都可以在拖动滚动条时,固定元素于指定位置。但是在效果上,前者是直接固定于指定位置,后者在达到临界值时进行固定。在性质上,前者脱离文档流,后者不脱离文档流。在使用上,前者无需指定 top、button、left、right 中的任一值,就能以当前视口进行定位。后者必须指定其一,然后相对最近滚动祖先进行偏移。

Ghost 源码的视窗单位出现较丰富。viewport 是浏览器实际显示内容的区域,即不包括工具栏的浏览器区域。

// A viewport with width 1000px and height 800px
vw => viewport 高度的百分比 => 50vw = 500px
vh => viewport 高度的百分比 => 50vh = 400px
vmin => vw 和 vh 中较小的值作为百分比单位 => 50vim = 400px
vmax => vw 和 vh 中较大的值作为百分比单位 => 50vmax = 500px
/* 应用 */
/* 子元素的大小相对于窗口改变而不是父元素 */
.parent { width: 100px; }
.child { width: 50vw; }
/* 响应垂直居中 */
.verticalresponsecentering{ width: 50vw; height: 50vh; margin: 25vh auto; }
.verticalresponsecentering{ width: 50vw; height: 50vh; margin: 25vh 25vw; }

Javascript

在效果实现的过程中,滚动监听事件获取当前页面滚动距离,应注意页面端与到移动端的兼容;同时也应注意获取元素尺寸与视口高宽的问题。相关链接于文末。

// 兼容性
// 页面端支持 document.documentElement,移动端支持 document.body
var scrollY = document.documentElement.scrollTop || document.body.scrollTop;
// 自动识别不同平台上的滚动容器 => document.scrollingElement
// 在桌面端 document.scrollingElement 就是 document.documentElement/在移动端 document.scrollingElement 就是 document.body
document.scrollingElement.scrollTop = 0; // 一行代码回滚顶部 => 兼容pc与移动
// 注意 document.body.xxx 情况
Element.clientWidth|clientHeight // 元素的内部宽度|高度 => 包括内边距 padding,但不包括边框 border、外边距 margin 和垂直滚动条
Element.offsetWidth|offsetHeight // 只读属性 => 返回一个元素的布局宽度|高度 => 包含元素的边框 border、水平线上的内边距 padding、竖直方向滚动条 scrollbar
Element.scrollWidth|scrollHeight // 元素内容宽度|高度的度量
Element.scrollTop|scrollLeft // 获取或设置一个元素的内容垂直滚动的像素数|读取或设置元素滚动条到元素左边的距离

window.screenTop|screenLeft // 从包括工具栏的用户浏览器上|左边界到屏幕最顶端距离
window.screen.height|width // 返回访问者屏幕的高度|宽度 => 900|1440
window.innerHeight|innerWidth // 视窗的内部高度|宽度

window.pageYOffset // 只读 => scrollTop 可设置,具有回顶部效果

window.screen.availHeight|availWidth // 返回包含工具栏的浏览器窗口在屏幕上可占用的垂直|水平空间,即最大高度|宽度 => 825|1440
Element size and scrolling
G&TOC&CSS
CSS Grid 网格布局教程 - 阮一峰的网络日志
G&TOC&CSS

结束

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议,转载请注明出处!

]]>
<![CDATA[MySQL]]>https://zairesinatra.github.io//mysqlnote/6264edb6aa2820757fbb143eWed, 22 Dec 2021 13:11:00 GMT

快速上手

Quick Start

MySQL

数据库是有组织的数据容器。表是存储特定类型数据的结构化文件。模式说明库与表的布局与特性信息。列是表中的一个字段,所有表都由一个或多个列组成。行作为表中的一个记录。唯一标识表中每行的这个列或这组列,称为主键。没有主键则无法安全更新或删除表中的特定行。任何列都可以作为主键,但是需满足任意两行都不具有相同的主键值且每行都必须具有一个非 NULL 的主键值的条件。

默认数据库:

数据库 描述
infomation_schema 信息数据库,包括 MySQL 在维护的其他数据库、表、列访问权限
performance_schema 性能数据库,记录 MySQL Server 运行过程中的一些资源消耗相关信息
mysql 用于存储数据库管理者的用户信息、权限信息以及日志信息
sys 简易版的 performance_schema,将性能数据库的数据汇总成易理解形式

SELECT 语句完整结构:

  • SELECT:要返回的列或者表达式 -> 必须使用
  • FROM:检索数据的表 -> 仅在从表中选择数据时使用
  • WHERE:行级过滤 -> 非必须使用
  • GROUP BY:分组说明 -> 仅在按组计算聚集时使用
  • HAVING:组级过滤 -> 非必须使用
  • ORDER BY:输出排序顺序 -> 非必须使用
  • LIMIT:要检索的行数 -> 非必须使用
// sql92
SELECT ...(聚合函数) FROM ... WHERE 多表连接条件 AND 不包含聚合函数的过滤条件 GROUP BY ... HAVING 包含聚合函数的过滤条件 ORDER BY ...(ASC/DESC) LIMIT ...
// sql99
SELECT ...(聚合函数) FROM ... (LEFT/RIGHT) JOIN ... ON ... (多表的连接条件) (LEFT/RIGHT) JOIN ... ON ... (多表的连接条件) WHERE 不包含聚合函数的过滤条件 GROUP BY ... HAVING 包含聚合函数的过滤条件 ORDER BY ...(ASC/DESC) LIMIT ...

SQL 底层执行原理:产生一系列虚拟表

先执行 FROM 关键字,多表联查会通过 CROSS JOIN 求笛卡尔积,将得到的虚拟表进行 ON 筛选,此过程在 VT1-1 的基础上得到 VT1-2。若使用到左、右连接或者全连接,那么会在 VT1-2 的基础上增加外部行产生 VT1-3。

通过 VT1 获取原始数据后进行 WHERE 操作,根据结果筛选过滤得到 VT2。

GROUP BYHAVING 对 VT2 进行分组和过滤得到 VT3 和 VT4。

SELECTDISTINCT 提取所需字段并过滤掉重复的行得到 VT5-1 和 VT5-2。

ORDER BYLIMIT 对指定的字段排序并通过分页取出指定行,得到最终的 VT6 与 VT7。

// FROM 首先加载、SELECT 视作索引首位处于第二加载、ORDER BY 与 LIMIT 看作索引末尾最后加载
FROM ... 多表联查会先 CROSS JOIN 求笛卡尔积 => ON 连接条件过滤 => 关注左右连接 => WHERE 过滤数据 => GROUP BY 按要求分组 => HAVING 聚合条件筛选 => SELECT => DISTINCT => ORDER BY => LIMIT

创建并管理数据库

仅通过 CREATE DATABASE 创建数据库时,字符集会使用数据库的默认设置。

显示指明字符集:CREATE DATABASE 数据库名 CHARACTER SET '字符集';

# 创建数据库若存在 => 不成功不报错
CREATE DATABASE IF NOT EXISTS 数据库名 CHARACTER SET '字符集';

更改现有数据库字符集:ALTER DATABASE 数据库名 CHARACTER SET 字符集;

区分 SHOW DATABASES; & SELECT DATABASE();:前者查看所有数据库,后者是通过一个全局函数查看当前正在使用的数据库。

查看指定库下的所有表:SHOW TABLES FROM 数据库名;

查看数据库的创建语句:SHOW CREATE DATABASE 数据库名\G\G 表示格式化输出信息。

删除数据库时最好能加以限制:DROP DATABASE IF EXISTS 数据库名;

建表时必须指定表名、字段名、类型和长度,约束条件和默认值的设定不必须。

CREATE TABLE IF NOT EXISTS myempl(id INT,emp_name VARCHAR(15),hire_date DATE);

表的结构描述:DESC ???;;表的创建语句:SHOW CREATE TABLE ???;

在现有表的基础上创建新表(选择字段别名作为新表的字段名)常应用于表复制。

CREATE TABLE 新表名 AS SELECT FIELD01 FIELD01ALIAS, FIELD02, ... FROM 现有表;
# 创建新表 employees_copy 实现对 employees 表复制 => 包含表数据
CREATE TABLE employees_copy AS SELECT * FROM employees;
# 创建新表 employees_copy 实现对 employees 表复制 => 不包含表数据 => 不可能条件
CREATE TABLE employees_copy AS SELECT * FROM employees WHERE department_id > 10000;
CREATE TABLE employees_copy AS SELECT * FROM employees WHERE 1=2;

修改表 ALTER TABLE ??? 后边可以指定具体执行的操作:

  • 添加字段:... ADD 字段名 字段类型 [FIRST|AFTER 字段名];
  • 修改字段(通常不修改类型):... MODIFY FIELD TYPE DEFAULT X;
  • 重命名字段:... CHANGE old_col_name new_col_name TYPE;
  • 删除字段:... DROP COLUMN col_name;

重命名表:

  • RENAME TABLE old_name TO new_name;
  • ALTER TABLE old_name RENAME TO new_name;

删除表数据:

  • DROP ???:表的结构和数据都会被删除,且释放空间
  • TRUNCATE ???:仅清空表中的所有数据,表结构会被保留

执行 COMMIT 提交的数据会被永久保存,不可回滚,后续修改需提交事务。

ROLLBACK 可以使得数据回滚到最近一次 COMMIT 之后:

  • 执行过 TRUNCATE 不能回滚
  • 使用 DELETE 语句删除的数据可以回滚

DDL 的操作一旦执行就不可回滚,指令 SET autocommit = FALSE 对其失效。

DDL 操作会追加执行 COMMIT。此提交不受 SET autocommit = FALSE 影响。

区分 TRUNCATEDELETE

  • TRUNCATE 在功能上与不带 WHEREDELETE 语句相同
  • 虽然 TRUNCATE 速度更快,且使用的系统和事务日志资源较少,但无事务和不触发 TRIGGER 有可能会造成事故,不建议在开发环境中使用。

Data manipulation language

DML 是一系列描述增删改操作的 SQL 语句。SELECT 有时也会被包括其中。

INSERT INTO [TABLE_NAME] (字段1, 字段2, ...) SELECT 对应字段1, 对应字段2, ... FROM [TABLE_NAME_other] WHERE ...;

DML statements for an InnoDB table operate in the context of a transaction, so their effects can be committed or rolled back as a single unit. dev.mysql

DML 操作同样默认为执行后不能回滚。若在执行操作前指定 SET autocommit = FALSE,那么执行的 DML 操作依然可回滚。

查询 & 函数

Multi-table Query Core Concepts

多表查询也称为关联查询,是由多个表一起完成的查询操作。多表查询的意义在于减少网络交互次数。通过单条语句完成需要多个简单 SQL 语句才能完成的目的。

多表查询出现笛卡尔乘积的原因可能是关联条件失效,或省略多个表的连接条件。

# 笛卡尔乘积的错误形式
SELECT employee_id,department_name FROM employees,departments;
SELECT * FROM employees,departments;
SELECT employee_id,department_name FROM employees CROSS JOIN departments;

从 SQL 优化角度来说,建议多表查询时对每个字段指明其所在的表。

# FROM 语句使用的别名也可以在 SELECT 语句里使用 => 起了别名就必须用别名
SELECT emp.employee_id,dept.department_name,emp.department_id FROM employees emp,departments dept WHERE emp.department_id = dept.department_id;

如果有 n 张表要实现多表查询,则至少需要 n-1 个连接条件。

SELECT emp.employee_id,emp.last_name,dept.department_name,emp.department_id,loc.city,loc.location_id FROM employees emp,departments dept,locations loc WHERE emp.department_id = dept.department_id AND dept.location_id = loc.location_id;

当查询请求涉及到多个表,且连接表的条件为相等时,即等值连接查询;由其他的运算符连接就是非等值查询

SELECT e.last_name,e.salary,j.grade_level FROM employees e, job_grades j WHERE e.salary >= j.lowest_sal AND e.salary <= j.highest_sal;

不同表之间实现连接操作称为非自连接,表自行与自己的连接称为自连接

# 查询员工id、姓名以及管理者的id、姓名
SELECT emp.employee_id,emp.last_name,mgr.employee_id,mgr.last_name FROM employees emp, employees mgr WHERE emp.manager_id = mgr.employee_id;

Inner Join & Outer Join

The different types of the JOINs in SQL:

  • (INNER) JOIN: Returns records that have matching values in both tables
  • LEFT (OUTER) JOIN: Returns all records from the left table, and the matched records from the right table
  • RIGHT (OUTER) JOIN: Returns all records from the right table, and the matched records from the left table
  • FULL (OUTER) JOIN: Returns all records when there is a match in either left or right table
MySQL
# 右上图 => 右外连接
SELECT employee_id,department_name FROM employees e RIGHT JOIN departments d ON e.department_id = d.department_id;
# 左中图
SELECT employee_id,department_name FROM employees e LEFT JOIN departments d ON e.department_id = d.department_id WHERE d.department_id IS NULL;
# 右中图
SELECT employee_id,department_name FROM employees e RIGHT JOIN departments d ON e.department_id = d.department_id WHERE e.department_id IS NULL;
# 左下图 => 满外连接 => 左上图 UNION ALL 右中图
SELECT employee_id,department_name FROM employees e LEFT OUTER JOIN departments d ON e.department_id = d.department_id UNION ALL SELECT employee_id,department_name FROM employees e RIGHT JOIN departments d ON e.department_id = d.department_id WHERE e.department_id IS NULL;
# 右下图 => 左中图 UNION ALL 右中图
SELECT employee_id,department_name FROM employees e LEFT JOIN departments d ON e.department_id = d.department_id WHERE d.department_id IS NULL UNION ALL SELECT employee_id,department_name FROM employees e RIGHT JOIN departments d ON e.department_id = d.department_id WHERE e.department_id IS NULL; 

MySQL 不支持 SQL92 中以 (+) 标记从表位置的外连接写法。

# SQL92 实现左外连接
SELECT employee_id,department_name FROM employees e,departments d WHERE e.`department_id`=d.`department_id`(+);

MySQL 不支持全外连接 FULL OUTER JOIN 写法,需要利用 UNION 实现全外连接操作。UNION 可以将多条 SELECT 语句的结果组成单个结果集。

SELECT column,... FROM table1 UNION [ALL] SELECT column,... FROM table2;

由于不去重,UNION ALL 执行语句时所需的资源较 UNION 更少。当明确知道合并后结果不存在重复的情况时,使用 UNION ALL 可以提高查询效率。

Subquery

A subquery in MySQL is a query, which is nested into another SQL query and embedded with SELECT, INSERT, UPDATE or DELETE statement along with the various operators.

子查询 subquery 是可嵌套在查询中的内部查询,从内向外进行处理。

子查询会在主查询执行前完成,其结果会被主查询使用。子查询使用括号包裹,使用时放在比较条件的右侧可读性更高。

# 查找出较 Abel 更高工资的同事并列出薪资
# 方式一
SELECT salary FROM employees WHERE last_name = 'Abel';
SELECT last_name,salary FROM employees WHERE salary > 11000;
# 方式二 => 自连接
SELECT e2.last_name,e2.salary FROM employees e1,employees e2 WHERE e1.last_name = 'Abel' AND e1.`salary` < e2.`salary`;
# 方式三 => 子查询
SELECT last_name,salary FROM employees WHERE salary > ( SELECT salary FROM employees WHERE last_name = 'Abel' );

按内查询结果所返回的记录条目,可将子查询分为单行子查询、多行子查询。按子查询是否被执行多次,可将子查询分为关联子查询和非关联子查询。

A row subquery is a subquery variant that returns a single row and can thus return more than one column value.

单行子查询是一种子查询的变体,返回单行,但可以包含多个列值。

# 返回公司工资最少的员工的 last_name、job_id 和 salary
SELECT last_name,job_id,salary FROM employees WHERE salary = ( SELECT MIN(salary) FROM employees );
# 查询与141号员工的manager_id和department_id相同的其他员工的employee_id,manager_id,department_id
SELECT employee_id, manager_id, department_id FROM employees WHERE manager_id = ( SELECT manager_id FROM employees WHERE employee_id = 141 ) AND department_id = ( SELECT department_id FROM employees WHERE employee_id = 141 ) AND employee_id <> 141;
# 查询与141号或174号员工的manager_id和department_id相同的其他员工的employee_id,manager_id,department_id
SELECT employee_id, manager_id, department_id FROM employees WHERE manager_id IN (SELECT  manager_id FROM employees WHERE employee_id IN (174,141)) AND department_id IN (SELECT  department_id FROM employees WHERE employee_id IN (174,141)) AND employee_id NOT IN(174,141);
# HAVING 中的子查询
# 查询最低工资大于50号部门最低工资的部门id和其最低工资
SELECT department_id, MIN(salary) AS dept_lowest_salary FROM employees WHERE department_id IS NOT NULL GROUP BY department_id HAVING dept_lowest_salary > ( SELECT MIN(salary) FROM employees WHERE department_id = 50);
# CASE中的子查询
# 显示员工的employee_id,last_name和location.其中,若员工department_id与location_id为1800的department_id相同,则location为'Canada',其余则为'USA'
SELECT employee_id, last_name, ( CASE department_id WHEN ( SELECT department_id FROM departments WHERE location_id = 1800 ) THEN 'Canada' ELSE 'USA' END ) "location" FROM employees;
# 子查询中的空值问题 => 内查询空值不报错,仅无结果
SELECT last_name, job_id FROM employees WHERE job_id = ( SELECT job_id FROM employees WHERE last_name = 'Haas' );
# 非法使用子查询 => ERROR 1242 (21000): Subquery returns more than 1 row
SELECT employee_id, last_name FROM employees WHERE salary = ( SELECT MIN( salary ) FROM employees GROUP BY department_id );
# 非法使用子查询 => 正解
# 查出哪些员工是等于下面各部门最低工资的人
SELECT employee_id, last_name FROM employees WHERE salary IN ( SELECT MIN( salary ) FROM employees GROUP BY department_id );

多行子查询可以返回多行的查询结果。因为需要返回多行,所以必须经过一系列的比较运算符(IN、ALL、ANY、SOME)处理。

# 查询平均工资最低的部门 id
SELECT department_id, AVG(salary) FROM employees GROUP BY department_id HAVING AVG(salary) = ( SELECT MIN(avg_salary) FROM ( SELECT AVG(salary) avg_salary FROM employees GROUP BY department_id ) AS avg_salary_table);
SELECT department_id FROM employees GROUP BY department_id HAVING AVG(salary) <= ALL ( SELECT AVG(salary) FROM employees GROUP BY department_id );
# 返回其它 job_id 中比 job_id 为 'IT_PROG' 部门任一工资低的员工的员工号、姓名、job_id 以及 salary
SELECT employee_id,last_name,job_id,salary FROM employees WHERE job_id <> 'IT_PROG' AND salary < ANY (SELECT salary FROM employees WHERE job_id = 'IT_PROG') AND job_id <> 'IT_PROG';
// 返回其它 job_id 中比 job_id 为 'IT_PROG' 部门所有工资都低的员工的员工号、姓名、job_id 以及 salary
SELECT employee_id,last_name,job_id,salary FROM employees WHERE job_id <> 'IT_PROG' AND salary < ALL (SELECT salary FROM employees WHERE job_id = 'IT_PROG') AND job_id <> 'IT_PROG';

子查询的执行依赖于外部查询,通常是因为子查询的表用到了外部的表,并存在条件关联。这种情况下,每执行一次外部查询,子查询都需要重新计算,这种形式的子查询称为关联子查询。

/*
相关子查询 => 子查询中使用主查询中的列
在SELECT中除了GROUP BY 和 LIMIT之外,其他位置都可以声明子查询
*/
// 查询员工中工资大于公司平均工资的员工的 last_name,salary 和其 department_id
SELECT last_name,salary,department_id FROM employees WHERE salary > ( SELECT AVG(salary) FROM employees);
// 查询员工中工资大于本部门平均工资的员工的 last_name,salary 和其 department_id
// 方式一 => 相关查询
SELECT last_name,salary,department_id FROM employees e1 WHERE salary > ( SELECT AVG(salary) FROM employees e2 WHERE department_id = e1.department_id);
// 方式二 => FROM子查询
SELECT e.last_name,e.salary,e.department_id FROM employees e,(SELECT department_id,AVG(salary) avg_sal FROM employees GROUP BY department_id) t_debt_avg_sal WHERE e.department_id = t_debt_avg_sal.department_id AND e.salary > t_debt_avg_sal.avg_sal;
// 查询员工的id,salary,按照department_name 排序
// 方式一 => 相关子查询
SELECT employee_id, salary FROM employees emp ORDER BY ( SELECT department_name FROM departments dept WHERE emp.department_id = dept.department_id ) ASC;
// 方式二 => 表连接
SELECT employee_id, salary, department_name FROM employees emp LEFT JOIN departments dept ON emp.department_id = dept.department_id ORDER BY department_name ASC;
// 若employees表中employee_id与job_history表中employee_id相同的数目不小于2则输出这些相同id的员工的employee_id,last_name和其job_id
SELECT e.employee_id,last_name,e.job_id FROM employees e WHERE 2 <= (SELECT COUNT(*) FROM job_history j WHERE e.employee_id = j.employee_id);

关联子查询通常会搭配 EXISTS 操作符:

  • 子查询中存在满足条件的行:返回 TRUE,停止子查询,返回符合的记录
  • 子查询中不存在满足条件的行:返回 FALSE,继续查找

NOTEXISTS 关键字表示如果不存在某种条件,则返回 TRUE,反之 FALSE

// 查询公司管理者的 employee_id, last_name, job_id, department_id 信息
// 方式一 => 自连接 => 将同一张表看做两张表进行等值连接
SELECT DISTINCT mgr.employee_id, mgr.last_name, mgr.job_id, mgr.department_id FROM employees emp JOIN employees mgr ON emp.manager_id = mgr.employee_id;
// 方式二 => 子查询的方式 => 先将所有的manager_id查出
SELECT employee_id, last_name, job_id, department_id FROM employees WHERE employee_id IN ( SELECT DISTINCT manager_id FROM employees );
// 方式三 => EXISTS
SELECT employee_id, last_name, job_id, department_id FROM employees e1 WHERE  EXISTS ( SELECT 1 FROM employees e2 WHERE e1.employee_id = e2.manager_id);
# 查询departments表中不存在于employees表中的部门的department_id和department_name (employees表中部门id为NULL的情况)
# 方式一 => 右外连接
SELECT dept.department_id, dept.department_name, emp.department_id FROM departments dept LEFT JOIN employees emp USING (department_id) WHERE emp.department_id IS NULL;
# 方式二 => NOT EXISTS
SELECT department_id, department_name FROM departments d WHERE NOT EXISTS (SELECT * FROM employees emp WHERE emp.department_id = d.department_id);

在多数的数据库中,自连接的处理速度要比子查询快得多:子查询是先对未知表进行查询,再使用条件来判断;自连接是对已知的数据表进行条件判断。

Built-in Functions

SQL 在不同 DBMS 间的差异性很大,远大于 SQL 不同版本之间的差异。

拼接符:多数 DBMS 使用 ||+,MySQL 字符串拼接函数为 concat()

MySQL

单行函数是指返回单行结果的函数。聚合函数是对一组数据进行汇总的函数。输入一组数据的集合,输出单个值。聚合函数不能嵌套调用。

常见聚合函数:

  • AVGSUMMAX/MINCOUNT...
  • GROUP BY:通常与聚合函数一起使用,对结果集进行分组
  • HAVING:根据指定的条件过滤分组

区分 count(*)count(1)count(列名) 的使用:

  • 三者都是获取指定字段的出现个数
  • count(col_name) 检索字段时,会忽略 NULL

SELECT 指定的非聚合函数字段必须在 GROUP BY 中出现。

GROUP BY 中出现的字段不一定要在 SELECT 中有声明。

// 查询各个department_id,job_id的平均工资
SELECT department_id,job_id,AVG(salary) FROM employees GROUP BY department_id,job_id;
SELECT job_id,department_id,AVG(salary) FROM employees GROUP BY job_id,department_id;
// 错误形式 job_id 未出现在 GROUP BY 中 => 一个部门不一定只有一个工种
SELECT department_id,job_id,AVG(salary) FROM employees GROUP BY department_id;

ROLLUPGROUP BY 子句的扩展,该选项将在结果集中添加一个额外的行以显示总计。

HAVING 子句通常与 GROUP BY 一起使用,若省略 GROUP BY,则 HAVING 子句的行为与 WHERE 子句类似。

HAVING 或者 WHERE 的选择:过滤条件中有聚合函数时须声明在 HAVING 内。

查询语法结构中,WHEREGROUP BY 之前,所以无法对分组结果进行筛选。

此外,WHERE 排除的记录不再包括在分组中。

// 查询各部门最高工资较10000高的部门
// 错误写法 => ERROR 1111 (HY000): Invalid use of group function
SELECT department_id,MAX(salary) FROM employees WHERE MAX(salary) > 10000 GROUP BY department_id;
// 正确写法
SELECT department_id,MAX(salary) FROM employees GROUP BY department_id HAVING MAX(salary) > 10000;
// 查询部门 id 为 10-40 四个部门中最高工资高于 10000 的部门信息
// 方式一 => 推荐 => 执行效率高
SELECT department_id, MAX(salary) FROM employees WHERE department_id IN (10,20,30,40) GROUP BY department_id HAVING MAX(salary) > 10000;
// 方式二
SELECT department_id, MAX(salary) FROM employees GROUP BY department_id HAVING MAX(salary) > 10000 AND department_id IN (10,20,30,40);

关联表数据需要连接时,WHERE 是先筛选后连接,HAVING 是先连接后筛选:

在关联查询中,前者比后者更高效。因为前者可以先筛选,用一个筛选后的较小数据集和关联表进行连接,占用的资源会比较少;后者需要先将结果集准备好,也就是用未被筛选的数据集进行关联,然后再对这个数据集进行筛选,这样所占用的资源就会比较多,执行效率也就较低。

数据类型

整数类型

超出设置的整数类型范围:Out of range value for column '?' at ...

整数类型 字节 有符号数取值范围 无符号数取值范围 (UNSIGNED)
TINYINT 1 -128~127 0~255
SMALLINT 2 -32768~32767 0~65535
MEDIUMINT 3 -8388608~8388607 0~16777215
INT、INTEGER 4 -2147483648~2147483647 0~4294967295
BIGINT 8 -9223372036854775808~9223372036854775807 0~18446744073709551615

MySQL 8.0.17:整数数据类型不推荐使用显示宽度属性:

整型数据类型可以在定义表结构时指定所需显示宽度,如果不指定,则系统会为每一种类型指定默认的宽度值。

所有整数类型都有一个可选的 UNSIGNED 无符号属性:

UNSIGNED 是在 MySQL 中用來設定數值只能是正的屬性,比如 tinyint 原本的範圍是 -128~128,加了 UNSIGNED 的屬性後,範圍就會變成從 0~255 這樣,對於一些欄位如果確定不會有負數,可以設定這個屬性增加資料長度。

mysql> CREATE TABLE test_int3(f1 INT UNSIGNED);
mysql> desc test_int3;
mysql> INSERT INTO test_int3 VALUES(4294967295);
mysql> INSERT INTO test_int3 VALUES(4294967296);
mysql> SELECT * FROm test_int3;
+------------+
| f1         |
+------------+
| 4294967295 |
+------------+
  • TINYINT:一般用于枚举数据(范围很小且固定)
  • SMALLINT:用于较小范围的统计数据(统计固定资产库存数量)
  • MEDIUMINT:用于较大整数的计算(机场每日的客流量)
  • INT、INTEGER:范围足够,一般情况不用考虑超限问题(商品编号)
  • BIGINT:处理巨大的整数(网站点击量、双十一的交易量、产品持仓)

浮点类型

浮点数类型的无符号数取值范围,只相当于有符号数取值范围的一半:不论有无符号,浮点数都会存储表示符号的部分。因此无符号数的取值范围,其实就是有符号数取值范围大于等于零的部分。

浮点类型单精度值使用 4 个字节,双精度值使用 8 个字节。

自 MySQL 8.0.17 开始,浮点类型(M,D) 的用法不再推荐使用:

FLOAT(M,D) or DOUBLE(M,D)M = 整数位 + 小数,D = 小数位。D <= M <= 255,0 <= D <= 30。FLOAT(5,2) 可以显示 -999.99-999.99。

定点数类型

定点数在数据库内部以字符串的形式进行存储。

定点数 DECIMAL(M,D)M 表示精度,D 表示标度。

默认 DECIMAL(10,0),当精度超出时会进行四舍五入处理。

DECIMAL 的存储空间不固定,由 M 决定,总占用的存储空间是 M+2 个字节。

浮点数与定点数的区别:

  • 长度一定时,浮点类型的可取值范围更大,但是不精准
  • 定点数类型的取值范围虽然相对较小,但是精准

位类型

BIT 类型中存储二进制值。没有指定二进制的位数 M 时,默认是 1 位。

位数最小值为 1,最大值为 64。

mysql> CREATE TABLE test_bit1(f1 BIT, f2 BIT(5), f3 BIT(64));
mysql> INSERT INTO test_bit1(f1,f2) VALUES (1,23);
mysql> SELECT * FROM test_bit1;
+------------+------------+------------+
| f1         | f2         | f3         |
+------------+------------+------------+
| 0x01       | 0x17       | NULL       |
+------------+------------+------------+
mysql> INSERT INTO test_bit1 (f1) VALUES (2);
ERROR 1406 (22001): Data too long for column 'f1' at row 1

日期时间类型

类型 名称 字节 日期格式 最小值 最大值
YEAR 1 YYYY或YY 1901 2155
TIME 时间 3 HH:MM:SS -838:59:59 838:59:59
DATE 日期 3 YYYY-MM-DD 1000-01-01 9999-12-03
DATETIME 日期时间 8 YYYY-MM-DD HH:MM:SS 1000-01-01 00:00:00 9999-12-31 23:59:59
TIMESTAMP 日期时间 4 YYYY-MM-DD HH:MM:SS 1970-01-01 00:00:00 UTC 2038-01-19 03:14:07UTC

YEAR 类型表示年份,存储空间 1 字节,是所有日期时间类型中占空间最小的。

4 位字符串或数字表示的 YEAR 类型,最小值为 1901,最大值为 2155。

2 位字符串格式表示的 YEAR 类型,最小值为 00,最大值为 99:

  • 01-69 表示 2001 到 2069
  • 70-99 表示 1970 到 1999
  • 取值整数的 0 或 00 表示 0000 年
  • 取值是日期/字符串的 '0' 表示 2000 年

YYYY-MM-DDDATE 类型表示日期,无时间部分。需要 3 个字节的存储空间。

在向 DATE 类型的字段插入数据时,需要满足一定的格式条件:

  • YYYY-MM-DD/YYYYMMDD 最小取值 1000-01-01,最大取值 9999-12-03
  • YY-MM-DD/YYMMDD 转化方式同两位的 YEAR
  • CURRENT_DATE()NOW() 函数会插入当前系统的日期

TIME 类型表示不包含日期部分的时间,也是需要 3 个字节的存储空间。

在向 TIME 类型的字段插入数据时,需要满足一定的格式条件:

  • 带有冒号的字符串:D 表示天,会被转化为小时
  • 不带有冒号的字符串或者数字:转化为 HH:MM:SS 进行存储

DATETIME 类型的格式为 DATETIME 结合,即 YYYY-MM-DD HH:MM:SS

DATETIME 类型需要 8 个字节的存储空间,在所有的日期时间类型中是最多的。

在向 DATETIME 类型的字段插入数据时,需要满足一定的格式条件:

  • YYYY-MM-DD HH:MM:SSYYYYMMDDHHMMSS 格式的字符串
  • YYYYMMDDHHMMSS 格式的数字
  • YY-MM-DD HH:MM:SSYYMMDDHHMMSS 字符串:年符合 YEAR 规则
  • CURRENT_TIMESTAMP()NOW()SYSDATE() 函数

TIMESTAMP 显示格式与 DATETIME 相同,但只需要 4 个字节的存储空间。

TIMESTAMP has a range of '1970-01-01 00:00:01' UTC to '2038-01-19 03:14:07' UTC. UTC Coordinated Universal Time 世界协调时间,也称作世界标准时间。

TIMESTAMP 存储的值会进行时区转换,即「当前时间」和 UTC 时间互相转换。

TIMESTAMPDATETIME 的区别:

  • TIMESTAMP 存储空间较小,表示的时间范围也较小;
  • TIMESTAMP 底层选择的是毫秒值存储,表示距 '1970-1-1 0:0:0' 的时间
  • 在比较大小或日期计算时,TIMESTAMP 更方便、更快;
  • TIMESTAMP 和时区有关,会根据用户的时区不同,显示不同的结果
  • DATETIME 只能反映出插入的时间,与时区无关
mysql> CREATE TABLE temp_time(d1 DATETIME,d2 TIMESTAMP);
mysql> INSERT INTO temp_time VALUES('2021-4-22 16:18:52','2021-4-22 16:18:52');
mysql> INSERT INTO temp_time VALUES(NOW(),NOW());
mysql> SELECT * FROM temp_time;
# 修改当前的时区
SET time_zone = '+9:00';
mysql> SELECT * FROM temp_time;

开发中用得最多的日期时间类型,就是 DATETIME,因为这个数据类型包括了完整的日期和时间信息,取值范围也最大,使用起来比较方便。

但是对于注册时间、商品发布时间等,不建议使用 DATETIME 存储,而是使用时间戳,因为 DATETIME 不便于计算。

mysql> SELECT UNIX_TIMESTAMP();

文本字符串类型

CHAR(M) 类型在不指定 M 时,默认是 1 个字符长度。

CHAR 类型字段在定义时所声明的字段长度即为该字段占的存储空间字节数。

保存数据的实际长度比声明小:

  • 存储数据时,右侧填充空格以达到指定的长度
  • 检索数据时,其字段会去除尾部空格

VARCHAR(M) 在定义时必须指定长度 M

VARCHAR 在不同 MySQL 版本:

  • 低于 4.0:varchar(20) 指 20 个字节。只能存 6 个 UTF8 汉字
  • 高于 5.0:varchar(20) 指 20 个字符。

在检索 VARCHAR 类型的字段数据时,会保留数据尾部的空格。VARCHAR 类型字段所占用的存储空间为字符串实际长度加 1 个字节。

CHARVARCHAR 的选择:

  • CHAR 适合存储较短的固定长度数据:门派号码、uuid
  • VARCHAR 具有动态长度的特性,适合频繁改动的信息
  • 根据存储引擎:
    • MyISAM 最好使用固定长度的数据列代替可变长度的数据列
    • MEMORY 目前都使用固定长度的数据行存储
    • InnoDB 建议使用 varchar 类型。

对于 InnoDB 数据表,其内部的存储格式并没有区分固定长度和可变长度列,所有的数据行都使用头指针指向数据列,主要影响其性能是数据行使用的存储总量。

TEXT 用于保存文本类型的字符串,不需要预先定义长度,不允许做主键。

文本字符串类型 特点 长度 长度范围 占用的存储空间 最大值
TINYTEXT 小文本、可变长度 L 0 <= L <= 255 L + 2 个字节 2155
TEXT 文本、可变长度 L 0 <= L <= 65535 L + 2 个字节 838:59:59
MEDIUMTEXT 中等文本、可变长度 L 0 <= L <= 16777215 L + 3 个字节 9999-12-03
LONGTEXT 大文本、可变长度 L 0 <= L<= 4294967295(相当于4GB) L + 4 个字节 9999-12-31 23:59:59
TIMESTAMP 日期时间 4 YYYY-MM-DD HH:MM:SS 1970-01-01 00:00:00 UTC 2038-01-19 03:14:07UTC

TEXT 文本类型适合存储较大的文本段,不需要设置默认值。

TEXTBLOB 类型的数据在删除后容易导致空洞(文件碎片多)。

对于频繁使用的表不建议包含 TEXT 类型字段,建议分表存储。

枚举类型 ENUM 的取值范围需要在定义字段时指定。

ENUM 类型只允许从已有的成员中选取单个值,且一次不能选取多个值。

其存储空间由定义 ENUM 类型时所指定的成员个数决定。

文本字符串类型 长度 长度范围 占用的存储空间
ENUM L 1 <= L <= 65535 1或2个字节

ENUM 所需要的字节存储空间:

  • 1~255 个成员需要 1 个字节的存储空间
  • 256~65535 个成员需要 2 个字节的存储空间
  • 上限最多为 65535 个字节的存储空间

SET 表示字符串对象,可以包含 0 个或多个成员,上限为 64。

SET 类型所占的存储空间取决于包含成员的个数。与 ENUM 不同,SET 在选取成员时,可以同时选择多个成员。对 SET 插入重复的成员时,重复的成员会被自动删除。

二进制字符串类型

BINARYVARBINARY 类似于 CHARVARCHAR,用于存储二进制字符串。

BINARY(M) 可以设置固定长度的二进制字符串,M 表示最多能存储的字节数。

可变长度的二进制字符串 VARBINARY(M) 中,M 必须设置(存储上限)。

二进制字符串类型 特点 值的长度 占用空间(字节)
BINARY(M) 固定长度 M (0 <= M <= 255) M个字节
VARBINARY(M) 可变长度 M(0 <= M <= 65535) M+1个字节

BLOB 类型适合存储一个二进制的大对象,常用于图片、音频和视频等。

实际开发中往往不会在数据库里直接存储较大的数据,而是将这些数据存储在服务器的磁盘目录,然后将这些数据的访问路径存储到数据库中。

二进制字符串类型 值的长度 长度范围 占用空间
TINYBLOB L 0 <= L <= 255 L + 1 个字节
BLOB L 0 <= L <= 65535(相当于64KB) L + 2 个字节
MEDIUMBLOB L 0 <= L <= 16777215 (相当于16MB) L + 3 个字节
LONGBLOB L 0 <= L <= 4294967295(相当于4GB) L + 4 个字节

TEXTBLOB 执行删除或更新操作后,可能会在表中留下空洞。建议定期使用 OPTIMIZE TABLE 命令进行碎片整理。

在开发中最好将这类数据分表管理,减少主表中的碎片,保持住固定长度数据行的性能优势,避免大量的值传输。

阿里巴巴《Java开发手册》之 MySQL 数据库:

  • 任何字段如果为非负数,必须是 UNSIGNED
    • 【强制】小数类型为 DECIMAL,禁止使用 FLOATDOUBLE
      • 说明:在存储的时候,FLOATDOUBLE 都存在精度损失的问题,很可能在比较值的时候,得到不正确的结果。如果存储的数据范围超过 DECIMAL 的范围,建议将数据拆成整数和小数并分开存储。
    • 【强制】如果存储的字符串长度几乎相等,使用 CHAR 定长字符串类型。
    • 【强制】VARCHAR 是可变长字符串,不预先分配存储空间,长度不要超过 5000。如果存储长度大于此值,定义字段类型为 TEXT,独立出来一张表,用主键来对应,避免影响其它字段索引效率。

约束

约束即对表中字段的限制,目的是保证数据的完整性。完整性又可以分为实体完整性、域完整性、引用完整性以及自定义完整性。

information_schema 系统库;table_constraints 专门存储各个表的约束的表

# 查看某个表已有的约束
SELECT * FROM information_schema.table_constraints WHERE table_name = '表名称';

NOT NULL Constraint

默认情况下,列可以包含 NULL 值。

空字符串 ' ' 不等于 NULL,0 也不等于 NULL

非空约束强制列不接受 NULL 值。

# 建表时添加非空约束
CREATE TABLE 表名称(...,字段名 数据类型 NOT NULL,...);
# 建表后添加非空约束
ALTER TABLE 表名称 MODIFY 字段名 数据类型 NOT NULL;
# 删除非空约束
ALTER TABLE 表名 MODIFY 字段名 数据类型 NULL;
ALTER TABLE 表名 MODIFY 字段名 数据类型;

UNIQUE Constraint

唯一约束可以确保列中的所有值都是不同的。唯一性约束允许列值为 NULL

每个表可以有多个 UNIQUE 约束,但每个表只能有一个 PRIMARY KEY 约束。

当创建唯一约束时未给一约束命名,那么默认和列名相同。

# 建表时添加唯一性约束
CREATE TABLE test2(id INT UNIQUE,last_name VARCHAR(15),email VARCHAR(25),salary DECIMAL(10,2),CONSTRAINT uni_test2_email UNIQUE(email));
DESC test2;
+-----------+---------------+------+-----+---------+-------+
| Field     | Type          | Null | Key | Default | Extra |
+-----------+---------------+------+-----+---------+-------+
| id        | int           | YES  | UNI | NULL    |       |
| last_name | varchar(15)   | YES  |     | NULL    |       |
| email     | varchar(25)   | YES  | UNI | NULL    |       |
| salary    | decimal(10,2) | YES  |     | NULL    |       |
+-----------+---------------+------+-----+---------+-------+
SELECT * FROM information_schema.table_constraints WHERE table_name = 'test2';
+--------------------+-------------------+-----------------+--------------+------------+-----------------+----------+
| CONSTRAINT_CATALOG | CONSTRAINT_SCHEMA | CONSTRAINT_NAME | TABLE_SCHEMA | TABLE_NAME | CONSTRAINT_TYPE | ENFORCED |
+--------------------+-------------------+-----------------+--------------+------------+-----------------+----------+
| def                | dbtest13          | id              | dbtest13     | test2      | UNIQUE          | YES      |
| def                | dbtest13          | uni_test2_email | dbtest13     | test2      | UNIQUE          | YES      |
+--------------------+-------------------+-----------------+--------------+------------+-----------------+----------+
# 可以向 unique 标识的字段添加 null 值
INSERT INTO test2 (id,last_name,email,salary) VALUES (2,'gz',NULL,99998);
INSERT INTO test2 (id,last_name,email,salary) VALUES (3,'hz',NULL,99997);
# 创建表后添加约束
ALTER TABLE 表名 ADD UNIQUE key(字段列表);
ALTER TABLE 表名 MODIFY 字段名 字段类型 UNIQUE;
# 若已存在列中数据相等的情况 => 需要修改后才可设置
mysql> ALTER TABLE test2 ADD CONSTRAINT uni_test2_sal UNIQUE (salary);
mysql> ALTER TABLE test2 MODIFY last_name VARCHAR(15) UNIQUE;
# 复合的唯一性约束 + 表级约束
mysql> CREATE TABLE USER(id INT,`name` VARCHAR(15), `password` VARCHAR(25), CONSTRAINT uni_user_name_pwd UNIQUE (`name`,`password`));
mysql> INSERT INTO USER VALUES (1, 'zs', 'abc');
# 复合约束存在一个字段有区别即可
mysql> INSERT INTO USER VALUES (1, 'zscopy', 'abc');

添加唯一约束的列上会自动创建唯一索引。删除唯一约束只能通过删除唯一索引的方式。删除时需要指定唯一索引名(唯一索引名和唯一约束名相同)。

创建唯一约束时未指定名称:

  • 单列默认和列名相同
  • 组合列默认和括号中的首个列名相同
  • 可以自定义唯一约束名
mysql> ALTER TABLE test2 DROP INDEX last_name;
mysql> ALTER TABLE test2 DROP INDEX uni_test2_sal;
mysql> SELECT * FROM information_schema.table_constraints WHERE table_name = 'test2';
mysql> DESC test2;

PRIMARY KEY Constraint

Entity integrity is concerned with ensuring that each row of a table has a unique and non-null primary key value; this is the same as saying that each row in a table represents a single instance of the entity type modelled by the table. wiki

主键约束用来唯一标识表中的每条记录。主键约束相当于唯一约束与非空约束的组合,主键约束列不允许重复,也不允许出现空值。

一张表最多只能有一个主键约束,主键约束可以在列级创建,也可以在表级创建。

主键约束对应着表中的一列或者多列(复合主键)。如果是多列组合的复合主键约束,那么这些列都不允许为空值,并且组合的值不允许重复。

自行命名的主键约束名无效。当创建主键约束时,系统会默认在指定列或列组合上建立对应的主键索引。

主键约束删除时,主键约束所对应的索引也会自动删除。注意:修改主键字段的值有可能会破坏数据的完整性。

# 创建表时添加约束
# 列级约束
mysql> CREATE TABLE test3 (id INT PRIMARY KEY,last_name VARCHAR(15),salary DECIMAL(10,2),email VARCHAR(25));
# 表级约束 => 没有必要起名
mysql> CREATE TABLE test4 (id INT,last_name VARCHAR(15),salary DECIMAL(10,2),email VARCHAR(25),CONSTRAINT pk_test4_id PRIMARY KEY(id));
mysql> SELECT * FROM information_schema.table_constraints WHERE table_name = 'test4';
mysql> INSERT INTO test4 VALUES(1,'zs',99999,'zs@mail.com');
mysql> INSERT INTO test4 VALUES(1,'zscopy',99999,'zscopy@mail.com');
ERROR 1062 (23000): Duplicate entry '1' for key 'test4.PRIMARY'
mysql> INSERT INTO test4 VALUES(NULL,'zscopy',99999,'zscopy@mail.com');
ERROR 1048 (23000): Column 'id' cannot be null
# 复合主键约束
mysql> CREATE TABLE test5(id INT,`name` VARCHAR(15),`password` VARCHAR(25),PRIMARY KEY (`name`,`password`));
mysql> INSERT INTO test5 VALUES (1,'zs','abc');
mysql> INSERT INTO test5 VALUES (1,'zscopy','abc');
# 多列组合的复合主键约束都不可以为NULL
mysql> INSERT INTO test5 VALUES (1,NULL,'abc');
ERROR 1048 (23000): Column 'name' cannot be null
/* 创建表后添加约束 */
mysql> CREATE TABLE test6(id INT,`name` VARCHAR(15),`password` VARCHAR(25));
mysql> ALTER table test6 ADD PRIMARY KEY (id);
mysql> DESC test6;
+----------+-------------+------+-----+---------+-------+
| Field    | Type        | Null | Key | Default | Extra |
+----------+-------------+------+-----+---------+-------+
| id       | int         | NO   | PRI | NULL    |       |
| name     | varchar(15) | YES  |     | NULL    |       |
| password | varchar(25) | YES  |     | NULL    |       |
+----------+-------------+------+-----+---------+-------+
# 删除主键约束 => 实际开发根本不会去做
ALTER TABLE 表名称 DROP PRIMARY KEY;

AUTO_INCREMENT

自增特性可以让某个字段的后续插入值自增。

若对主键设置自增,后续写 SQL 时就不需要再标记出主键列了。

一张表最多只能有一个自增列。当需要唯一标识或顺序时,可以设置自增。

自增约束的列必须是键:主键或者唯一键。

自增约束的列数据类型必须是整数类型:

  • 若自增列指定了 0 和 null:在当前最大值的基础上自增
  • 若自增列指定了具体值:直接赋值为具体值
# 创建表时自增
mysql> CREATE TABLE test7 (id INT PRIMARY KEY AUTO_INCREMENT, last_name VARCHAR(15));
mysql> INSERT INTO test7(last_name) VALUES ('zs');
mysql> SELECT * FROM test7;
+----+-----------+
| id | last_name |
+----+-----------+
|  1 | zs        |
+----+-----------+
# 创建表后自增
alter table 表名称 modify 字段名 数据类型 auto_increment;
mysql> CREATE TABLE test8 (id INT PRIMARY KEY, last_name VARCHAR(15));
mysql> ALTER TABLE TEST8 MODIFY ID INT AUTO_INCREMENT;
# 删除自增
alter table 表名称 modify 字段名 数据类型;
mysql> ALTER TABLE test8 MODIFY id INT;

MySQL 8.0 新特性:自增变量的持久化

MySQL 8.0 之前,InnoDB 不能恢复重启前的自增列。

# MySQL 5.7
mysql> CREATE TABLE test9(id INT PRIMARY KEY AUTO_INCREMENT);
mysql> INSERT INTO test9 VALUES (0),(0),(0),(0);
mysql> SELECT * FROM test9;
mysql> DELETE FROM test9 WHERE id = 4;
mysql> INSERT INTO test9 VALUES(0);
mysql> SELECT * FROM test9;
mysql> DELETE FROM test9 where id=5;
# --- 此时重启数据库 ---
# --- 新插入一个空值 ---
mysql> INSERT INTO test1 values(0);
# 新插入的 0 值分配的自增主键值是 4 => 按重启前的操作逻辑应该分配 6
mysql> SELECT * FROM test1;
+----+
| id |
+----+
|  1 |
|  2 |
|  3 |
|  4 |
+----+

MySQL 5.7 中自增主键的分配取决于 InnoDB 数据字典内部的一个计数器:

该计数器只在内存中维护,并不会持久化到磁盘。当数据库重启时,该计数器会被初始化。

MySQL 8.0 后,自增主键的计数器会持久化到重做日志中:

每次计数器发生改变,都会将改变写入重做日志。数据库重启时,InnoDB 会根据重做日志所保存的信息来初始化计数器的内存值。

FOREIGN KEY Constraint

MySQL supports foreign keys, which permit cross-referencing related data across tables, and foreign key constraints, which help keep the related data consistent. dev.mysql
A foreign key relationship involves a parent table that holds the initial column values, and a child table with column values that reference the parent column values. A foreign key constraint is defined on the child table.

外键是一张表中的某个字段或某些字段集合,这些字段其实是其他表中的主键。

主表即父表,是被引用参考的表;从表即子表,是引用别人的表。

从表的外键列,必须引用主表的主键或者唯一约束的列,因为被依赖的值必须是唯一的。

在创建外键约束时,若不给外键约束命名,默认名不是列名,而是自动产生的一个外键名。

创建表时就指定外键约束的话,应该先创建主表,再创建从表。在删除表时,应该先删除从表或者先删除外键约束,再删除主表。

一个表可以建立多个外键约束。

从表的外键列与主表的被参照列名字可以不相同,但是数据类型必须一样。如果类型不一样,创建子表时,就会出现错误 "ERROR 1005 (HY000)"。

设置外键约束时,系统会默认建立对应的普通索引,索引名是外键的约束名。删除外键约束后,必须手动删除对应的索引。

/* 在创建表时添加 */
# 创建主表与从表 -- 表级约束
mysql> CREATE TABLE dept1(dept_id INT,dept_name VARCHAR(15));
mysql> CREATE TABLE emp1(emp_id INT PRIMARY KEY AUTO_INCREMENT,emp_name VARCHAR(15),department_id INT,CONSTRAINT fk_emp1_dept_id FOREIGN KEY (department_id) REFERENCES dept1(dept_id)); # ERROR 1822 (HY000): Failed to add the foreign key constraint. Missing index for constraint 'fk_emp1_dept_id' in the referenced table 'dept1'
# 报错原因是主表没有主键约束 => 完成主键添加后再进行从表创建
mysql> ALTER TABLE dept1 ADD PRIMARY KEY (dept_id);
# 不能添加主表中没有的字段值 => 先向主表添加再向从表添加
mysql> INSERT INTO emp1 VALUES (1001,'zs',10); # ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails (`dbtest13`.`emp1`, CONSTRAINT `fk_emp1_dept_id` FOREIGN KEY (`department_id`) REFERENCES `dept1` (`dept_id`))
mysql> INSERT INTO dept1 VALUES (10, 'IT');
# 删除、更新失败 => 外键约束起作用
mysql> DELETE FROM dept1 WHERE dept_id = 10; # ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails (`dbtest13`.`emp1`, CONSTRAINT `fk_emp1_dept_id` FOREIGN KEY (`department_id`) REFERENCES `dept1` (`dept_id`))
mysql> UPDATE dept1 SET dept_id = 20 WHERE dept_id = 10; # ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails (`dbtest13`.`emp1`, CONSTRAINT `fk_emp1_dept_id` FOREIGN KEY (`department_id`) REFERENCES `dept1` (`dept_id`))
# 在创建表后添加外键约束
mysql> CREATE TABLE dept2(dept_id INT PRIMARY KEY,dept_name VARCHAR(15));
mysql> CREATE TABLE emp2(emp_id INT PRIMARY KEY AUTO_INCREMENT,emp_name VARCHAR(15),department_id INT);
mysql> SELECT * FROM information_schema.table_constraints WHERE table_name = 'emp2';
mysql> ALTER TABLE emp2 ADD CONSTRAINT fk_emp2_dept2_id FOREIGN KEY (department_id) REFERENCES dept2(dept_id);
mysql> SELECT * FROM information_schema.table_constraints WHERE table_name = 'emp2';

删除外键约束:

  1. 查看约束名 + 删除外键约束
  2. 查看索引名 + 删除索引
SELECT * FROM information_schema.table_constraints WHERE table_name = '表名称'; # 查看某个表的约束名
ALTER TABLE 从表名 DROP FOREIGN KEY 外键约束名;
SHOW INDEX FROM 表名称; # 查看某个表的索引名
ALTER TABLE 从表名 DROP INDEX 索引名;
mysql> SELECT * FROM information_schema.table_constraints WHERE table_name = 'emp1';
mysql> ALTER TABLE emp1 DROP FOREIGN KEY fk_emp1_dept_id; # 删除外键约束
mysql> ALTER TABLE emp1 DROP INDEX fk_emp1_dept_id; # 删除外键约束对应普通索引

约束等级:

  1. Cascade:父表上更新、删除时,子表同步操作匹配记录
  2. Set null:父表上更新、删除记录时,子表上匹配记录的列设为 null
  3. No action:若子表中有匹配的记录,则不允许对父表相关键操作
  4. Restrict:同 no action,立即检查外键约束
  5. Set default:父表变更时,子表将外键列设置成默认的值(Innodb 不识)
  6. 没有指定等级相当于 Restrict
  7. 外键约束最好采用 ON UPDATE CASCADE ON DELETE RESTRICT

在 MySQL 里,外键约束是有成本的,需要消耗系统资源。对于大并发的 SQL 操作,有可能会不适合。比如大型网站的中央数据库,可能会因为外键约束的系统开销而变得非常慢。所以, MySQL 允许不使用系统自带的外键约束,在应用层面完成检查数据一致性的逻辑。也就是说,即使不用外键约束,也要想办法通过应用层面的附加逻辑,来实现外键约束的功能,确保数据的一致性。

《阿里开发规范》
【强制】不得使用外键与级联,一切外键概念必须在应用层解决。

说明:(概念解释)学生表中的 student_id 是主键,那么成绩表中的 student_id 则为外键。如果更新学生表中的 student_id,同时触发成绩表中的 student_id 更新,即为级联更新。外键与级联更新适用于单机低并发,不适合分布式、高并发集群;级联更新是强阻塞,存在数据库更新风暴的风险;外键影响数据库的插入速度。

CHECK Constraint

CHECK 约束用于检查某个字段的值是否符合要求,一般是指值的范围。

MySQL 5.7 中 CHECK Constraint 无效(没有错误或警告提示)。

mysql> CREATE TABLE test10(id INT,last_name VARCHAR(15), salary DECIMAL(10,2) CHECK(salary > 3000));
mysql> INSERT INTO test10 VALUES(1,'hz',3500);
# 添加失败
mysql> INSERT INTO test10 VALUES(2,'hzcopy',1500); # ERROR 3819 (HY000): Check constraint 'test10_chk_1' is violated.

DEFAULT Constraint

DEFAULT 约束用于为列设置默认值。

若插入数据时未显示赋值,那么默认值将会被添加。

# 在创建表时添加默认值
CREATE TABLE 表名(字段 类型 DEFAULT 默认值);
# 在创建表后添加默认值
ALTER TABLE 表名 MODIFY 字段名 字段类型 DEFAULT 默认值;
# 删除默认值
mysql> ALTER TABLE test12 MODIFY salary DECIMAL (10,2);

其他数据库对象

常见的数据库对象 描述
表 TABLE 存储数据的逻辑单元,以行和列的形式存在,列就是字段,行就是记录
数据字典 存放数据库相关信息的系统表 => 系统表的数据通常由数据库系统维护,程序员通常不应该修改,只可查看
约束 CONSTRAINT 执行数据校验的规则,用于保证数据完整性的规则
视图 VIEW 一个或者多个数据表里数据的逻辑显示;视图并不存储数据
索引 INDEX 用于提高查询性能,相当于书的目录
存储过程 PROCEDURE 用于完成一次完整的业务处理,没有返回值,但可通过传出参数将多个值传给调用环境
存储函数 FUNCTION 用于完成一次特定的计算,具有一个返回值
触发器 TRIGGER 相当于一个事件监听器,当数据库发生特定事件后,触发器被触发,完成相应的处理

视图

视图可以使用表的一部分,也可以针对不同需求制定不同的查询视图,即针对指定的人员只展示部分的数据。本质是一段存储起来的 SELECT。

视图建立在已有表(基表)的基础上,可以看作是虚拟表,本身不具有数据,占用很少的内存空间。视图的创建和删除只影响视图本身,不影响对应的基表。但是当对视图中的数据进行增删改操作时,数据表中的数据会发生相应变化,反之亦然。

  • 创建视图
# 在 CREATE VIEW 语句中嵌入子查询
CREATE [OR REPLACE] [ALGORITHM = {UNDEFINED | MERGE | TEMPTABLE}] VIEW 视图名称 [(字段列表)] AS 查询语句 [WITH [CASCADED|LOCAL] CHECK OPTION]

结束

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议,转载请注明出处!

]]>
<![CDATA[组件通讯]]>https://zairesinatra.github.io//zu-jian-tong-xun/6264eec5aa2820757fbb1454Fri, 01 Oct 2021 13:09:00 GMT

就地取材

全局事件总线 EventBus

组件通讯

将 Vue 实例赋值给 Vue 显式原型上增加的 $EventBus 属性,后续所有的通信数据和事件监听都存储在这个属性上。在范围较小时可以直接定义变量作为事件中心。

Vue.prototype.$eventBus = new Vue() // main.js
this.$eventBus.$emit('eventTarget','eventTargetVal') // 传值组件
this.$eventBus.$on('eventTarget', (val) => { ...val })// 接收组件
this.$eventBus.$off('eventTarget') // 销毁事件

选项和实例 Property

  • inheritAttrs => 抹去直接绑定子组件根元素上未作为 props 的 attribute
export default {
  inheritAttrs: false, ...
}
  • vm.$attrs => 父作用域中不被 prop 获取的 attribute 绑定

  • vm.$listeners => 包含父作用域中不含 .native 修饰器的 v-on 事件监听器

vm.$attrs 与 vm.$listeners 可分别通过 v-bind="$attrs" 和 v-on="$listeners" 传入内部组件。内部组件可通过 this.$emit('XXX') 直接触发传入的事件。

<template>
  <div>
    SupComponent
    <AttrsListenerTest ok="ok" okk="okk" okkk="okkk" @supHandler01="supHandler01" v-on:supHandler02="supHandler02"></AttrsListenerTest>
  </div>
</template>
<script>
import AttrsListenerTest from '@/components/AttrsListenerTest.vue'
export default {
  components: { AttrsListenerTest },
  methods: {
    supHandler01 () { console.log('super func 01') },
    supHandler02 () { console.log('super func 02') }
  }
}
</script>
<template>
  <div>
    AttrsListenerTest
    <SubAttrsListenerTest v-bind="$attrs" v-on="$listeners"/>
  </div>
</template>
<script>
import SubAttrsListenerTest from '@/components/SubAttrsListenerTest.vue'
export default {
  inheritAttrs: false, // 抹去绑定在根元素上的 $attrs 属性
  name: 'AttrsListenerTest',
  props: { ok: { type: String } },
  mounted () {
    console.log(this.$attrs) // {okk: 'okk', okkk: 'okkk'}
    console.log(this.$listeners) // {supHandler01: ƒ, supHandler02: ƒ}
  },
  components: { SubAttrsListenerTest }
}
</script>
<template>
  <div>SubAttrsListenerTest</div>
</template>
<script>
export default {
  name: 'SubAttrsListenerTest',
  props: { okk: { type: String } },
  mounted () {
    console.log(this.$attrs) // {okkk: 'okkk'}
    console.log(this.$listeners) // {supHandler01: ƒ, supHandler02: ƒ}
  }
}
</script>

选项和实例

  • vm.$parent => 指定已创建的实例的父实例

子组件在挂载完成后可通过 this.$parent 拿到其父组件实例,和父组件实例上的属性和方法。

父组件在挂载完成后可通过 this.$children 拿到一级子组件的属性和方法,那么就可以直接改变 data 或调用 methods 方法。

  • vm.$refs => 含注册过 ref attribute 的所有 DOM 元素组件实例的对象
  • vm.$root => 当前组件树的根 Vue 实例

所有组件最终都会挂载到根实例上,可通过根实例的 $children 获取子组件。

this.$root // 根实例
this.$root.$children[0] // 根实例的一级子组件
this.$root.$children[0].$children[0] // 根实例的二级子组件

pubsub-js

同样适用于任意组件间的通讯方式,消息订阅与发布。由于铺天盖地的相关库,这里主要说 PubSubJS

# 安装 pubsub
npm i pubsub-js

Vuex

快速上手

  • 安装与使用

VueCLI 勾选 Vuex 选项后,创建项目时会在 src 目录下生成 store 文件夹,其中包含 index.ts 文件。main.js|ts 会引入 store 进行全局注测,方便通过 this.$store 访问。项目阶段需要引入也可使用 npm install vuex@next --save 安装。

// src/store/index.js => vue2
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({ state: {}, mutations: {}, actions: {}, modules: {} })

// src/store/index.ts => vue3
import { createStore } from 'vuex'
export default createStore({ state: {}, mutations: {}, actions: {}, modules: {} })
// vue2 => main.js
import store from './store'
new Vue({
  el: '#app', router,
  store, // 使用 store
  template: '<App/>', components: { App }
})
// vue3 => main.ts
import store from './store'
...
createApp(App).use(store).use(router).mount('#app')
  • State => 唯一数据源 -> SSOT

Vuex 使用单一状态树,默认应用仅包含一个 Store 实例,即用一个对象包含全部的应用层级状态。需要注意的是,存储在 Vuex 中的数据和 Vue 实例中的 data 遵循相同的规则,即状态对象必须是纯粹 plain 的(普通对象)。

组件访问 State 中的数据可通过 this.$store.state.全局数据名称 或 mapState 辅助函数。后者是在 Vue 组件读取 Vuex 数据状态时,避免重复和冗余的声明计算属性,能接收对象或数组作为参数。

  • Getter => store 中的 getters => 派生 state 却不修改原 state

对于 store 的 state 来说,是多个组件需要的数据才会放入。那么同样的 state 派生状态也适合进行抽取封装。store 中定义 getters 可以认为是 store 的计算属性。

const store = createStore({
  state: {
    administrative_staff: [
      { id: 1, name: '...', del_flag: true }, { id: 2, name: '...', del_flag: false }
    ]
  },
  getters: {
    delAccountGet (state) {
      return state.administrative_staff.filter(p => p.done)
    }
  }
})

getters 会默认暴露为 store.getters 对象,可通过属性形式访问;且 getters 允许互相使用,getters 可以作为具体 getters 方法,即 getter 的第二个参数传入。

getters: {
  // ...
  delAccountCount (state, getters) { return getters.delAccountGet.length }
}

interface StoreOptions<S> 定义 getter 只接受两个参数,若需给 getters 传参,那么应该让 getter 返回一个函数来实现。但是这种形式不会进行结果的缓存。

getters: {
  // ...
  getASInfoById: (state) => (id) => { return state.administrative_staff.find(p => p.id === id) }
}
store.getters.getASInfoById(2) // -> { id: 2, name: '...', del_flag: false }

同样地,为简化将操作属性映射为组件中的方法,可通过传入对象(方法名不同于属性映射)或数组的 mapGetters() 辅助函数进行操作。

import { mapGetters } from 'vuex'
export default {
  ...,
  computed: {
    ...mapGetters([ // 使用对象展开运算符将 getter 混入 computed 对象中
      'delAccountCount', 'anotherGetter',
    ])
  }
}
  • Mutation => 必须是同步函数

变更 store 数据时不推荐直接对 this.$store.state.xx 赋值或运算,而是要唯一的通过提交 mutation 来改变 store 的状态。每个 mutation 都有一个字符串的事件类型 type,以及一个回调函数 handler。这个回调函数是实际对状态进行更改的位置,且接受 state 作为第一个参数。在 store 中定义的 mutation 更像是事件的注册,只能通过 store.commit('mutationName') 触发,mutationName 对应 type。

// store/xxx.js|ts 文件
const store = new Vuex.Store({
  state: { 数据名:数据值, ... },
  mutations: {
    func01(state, args) {...}, func02(state, args) {...}, func03(state, args) {...},
  }
})
// .vue 组件触发 mutation
import { mapMutations } from 'vuex'
methods: {
  eventfunc() { this.$store.commit('func01') },
  ...mapMutations(['func02','func03'])
}

可以向 store.commit 传递参数,即向 mutation 提交载荷 Payload。载荷更多时候是传入一个包含多个字段的对象。

// ...
mutations: {
  increment (state, payload) { state.count += payload.amount }
}
store.commit('increment', { amount: 10 })

对象风格的提交方式 => store.commit 的参数可以逐个指定,也可以通过对象的形式指定。后者包含对应 mutation 的 type 字段以及其他载荷字段。

mutations: {
  increment (state, payload) { state.count += payload.amount }
}
store.commit({ type: 'increment', amount: 10 })
  • Action => 提交 mutation 而不是直接变更状态 => 可异步

Mutation 中不能写异步代码,Action 可以处理异步任务。开发中通常以 Action 间接地触发 Mutation 达到变更数据的目的。

函数 Action 接受一个与 store 实例具有相同方法和属性的 context 对象,因此可以调用 context.commit 提交 mutation,或通过 context.state 和 context.getters 来获取 state 和 getters。开发中可用参数解构来简化多次调用 commit 的情况。

/* ActionContext 源码 */
export interface ActionContext<S, R> {
  dispatch: Dispatch;
  commit: Commit;
  state: S;
  getters: any;
  rootState: R;
  rootGetters: any;
}
// store/xxx.js|ts
const store = new Vuex Store({
  ...
  mutations: { func01(state) { ... }, func02(state, args) { ... }, ... },
  actions: {
    func01Sync({ commit }) { // ES2015 参数解构
      commit('func01')
    },
	func02Async(context, payload) {
	  setTimeout(() => { context.commit('func02', payload.args01) }, 1000)
	},
    incrementAsync(context, acc) { ... },...
  }
})

Action 通过 store.dispatch 方法触发。若在组件中涉及到异步操作,那么还是得分派 Action 再提交通知给必须同步执行的 Mutation 改变数据。此外 Actions 支持同样的载荷方式和对象方式进行分发。

// 组件分发 Actions
import { mapActions } from 'vuex'
methods: {
  ...mapActions(['func01Sync', '...']), // 映射导入后可以直接绑定到事件
  eventfunc() { this.$store.dispatch('func02ASync', {args01:'zs'}) }, // 载荷方式分发
  patchEventByObject() { this.$store.dispatch({ type: 'incrementAsync', amount: 10 }) // 对象方式分发
  }
}
// Action 进行异步操作
const module_user = {
  namespaced: true,
  state: () => ({...}), mutations: {...}, getters: {...},
  actions: {
    ...
    addUserByServer (context) {
      axios.get("").then(response => { // 默认引入 axios 与 nanoid
        context.commit(ADD_USER, {id:nanoid(),username:response.data})
      },error => {...})
    }
  }
}
  • module

虽然单一状态树会使应用的所有状态集中,但是当应用变得复杂时,代码就会变得臃肿。为此 Vuex 允许将 store 分割成模块 module。每个模块 module 拥有各自的 state、mutation、action、getter、甚至是嵌套子模块。

const moduleA = { state: () => ({ ... }), mutations: { ... }, actions: { ... }, getters: { ... } }
const moduleB = { state: () => ({ ... }), mutations: { ... }, actions: { ... } }
// Vuex3
const store = new Vuex.Store({
  modules: { a: moduleA, b: moduleB }
})
// Vuex4
// const store = createStore({ modules: { a: moduleA, b: moduleB } })
store.state.a // -> moduleA 的状态
store.state.b // -> moduleB 的状态

因 store 被分割成模块 module,那么每个模块的 state 都是局部状态。模块内部的 mutation 和 getter 接收的首个参数是模块的局部状态对象,后者所需的 rootState 根节点状态会作为第三个参数暴露出来。同样对于模块内部的 action,局部状态以及根节点状态都是通过 context.??? 暴露。

/* 源码分析 */
export type Mutation<S> = (state: S, payload?: any) => any;
export type Getter<S, R> = (state: S, getters: any, rootState: R, rootGetters: any) => any;
export interface ActionContext<S, R> { dispatch: Dispatch; commit: Commit; state: S; getters: any; rootState: R; rootGetters: any; }
  • 命名空间 => namespaced: true

模块内部的 action、mutation、getter 默认注册在全局命名空间,这种方式可以让多个模块对其进行操作,但是如果在不同的、无命名空间的模块中定义两个相同的 action、mutation 或 getter,那么会出现覆盖的情况,主要表现是后定义的覆盖前定义的。

在模块对象中设置字段 namespaced: true,会使其成为带命名空间的模块。这类模块注册后,getter、action 及 mutation 都会自动据模块注册路径调整命名。

开启命名空间后组件读取 State 数据 开启命名空间后组件读取 Getter 数据 开启命名空间后组件调用 Dispatch 开启命名空间后组件调用 Commit
直接读取 this.$store.state.moduleA.data01 this.$store.getters['moduleA/getter01'] this.$store.dispatch('moduleA/action01',args) this.$store.commit('moduleA/mutation01',args)
借助 mapXxx 读取 ...mapState('moduleA', ['data01',...]) ...mapGetters('moduleA',['getter01',...]) ...mapActions('moduleA',{methodName01:'action01',...}) ...mapMutations('moduleA',{method01:'mutation01',...})

简记:无效忠、忠效忠、笑笑打

默认情况下,模块内部的 action 和 mutation 仍然是注册在全局命名空间的——这样使得多个模块能够对同一个 action 或 mutation 作出响应。Getter 同样也默认注册在全局命名空间,但是目前这并非出于功能上的目的(仅仅是维持现状来避免非兼容性变更)。必须注意,不要在不同的、无命名空间的模块中定义两个相同的 getter 从而导致错误。点此

模块内未指明 namespaced: true 时,除 state 外,getters & mutation & action 默认注册在全局。

// 使用 A 模块内的 getters 数据 -> 未指明命名空间为真
$store.getters.getter01 => yes
$store.getters.moduleA.getter01 => no

小结

  • 和 getters 配置项功能类似的 computed 属性虽然可以完成运算功能,但却仅能在当前组件中生效,不能复用

  • 开发中最好使用简单的模板语法而不要过长的插值语法,所以说从 vuex 引入的 mapXxx 的意义是帮助简化取值的过程

Redux

Concepts & API

  • Flux 是强制单向数据流的架构

该模式控制派生的数据,通过中央存储来支持组件间的通信。整个应用中的数据更新在此完成。

Redux 是由 Flux 衍生的架构模式,用于集中管理多个组件所共享的状态。

Redux 中只能定义一个可以更新状态的 store,Flux 中可以定义多个 store。

  • Redux 和 React-redux 并非同一种东西

React-redux 就是把 Redux 架构模式和 Reactjs 结合起来的官方库,即 Redux 架构在 React 中的体现。

  • action => 具有 type 和 data 属性的一般对象

Actions are plain JavaScript objects that have a type field, which normally put any extra data needed to describe what's happening into the action.payload field. This could be a number, a string, or an object with multiple fields inside.

唯一改变状态树的方法是创建 Actions,一个描述发生了什么的对象,并将其 dispatch 给 store。

  • store => getState、dispatch、subscribe API

Reducers are functions that take the current state and an action as arguments, and return a new state result. In other words, (state, action) => newState.

在纯函数 reducer 中应该创建包含更新后字段的新 state 对象,而不是在其中直接修改 state 的值。

首次 reducer 调用是由 store 自动触发的,传递的先前状态是 undefined,且 action.type 通常是 @@INIT@@redux/INIT。随后的 reducer 执行会由 store.dispatch(action) 方法来触发。

返回更改的状态默认不会引起页面的更新。可在对应组件的 componentDidMount 中设置监听函数。

export default class Xxx extends Component {
  ...
  conponentDidMount(){
    store.subscribe(() => this.setState({})) // 触发页面更新
  }
}

subscribe 是一个用于注册回调函数的方法,可以监听 Store 中的状态变化,并在状态发生变化时触发回调函数。此外,subscribe 方法也会返回一个函数,可以用来取消对状态变化的监听。

// 优化触发更新 => 应用的 index.js 文件中包裹应用的渲染
store.subscribe(() => {ReactDOM.render(<App/>, document.getElementById("root"))})

Quick Start

createStore 函数用于生成整个应用唯一保存数据的容器 Store。

/* 
  store.js 用于暴露 store 对象,整个应用只有一个 store 对象
*/
//引入createStore,专门用于创建redux中最为核心的store对象
import { createStore, applyMiddleware } from "redux";
//引入为Count组件服务的reducer
import countReducer from "./count_reducer";
//引入redux-thunk,用于支持异步action
import thunk from "redux-thunk";
//暴露store
export default createStore(countReducer, applyMiddleware(thunk));

store.getState() 对 Store 生成快照,可以得到某个节点的数据集合 State。

状态变化时,应在组件中发出通知。接受 action 作为参数的 store.dispatch() 是 View 发出 action 的唯一方法。

// Xx组件.js
store.dispatch(xxxAction(value))
// Xx组件_action.js
export const xxxOperateAction = (data) => ({ type: xxxOperate, data });

Store 接受 Action 后生成新 State 的计算过程叫做 Reducer。

// Xx组件_reducer.js
const initState = 0; // 初始化状态
export default function xxReducer(preState = initState, action) {
  // redux会在开始时调用
  // 没有传递preState或者默认值为undefined则修改为0
  // console.log(preState, action); // {type: '@@redux/INIT1.6.3.4.l.f'}
  if (preState === undefined) preState = 0;
  const { type, data } = action;
  switch (type) {
    case "operate1":
      return preState ??? data;
    case "operate2":
      return preState ??? data;
    default:
      // 初始化 Store -> reducer 唤醒初始化
      return preState;
  }
}
  • reducer 纯函数无需手动调用,会被 store.dispatch() 自动触发执行

生成 store 时将 reducer 作为参数传入 createStore(xxxReducer) 方法。

纯函数 reducer 在相同输入的情况下,必定得到同样的输出。此函数中不能调用系统 IO 和 Date.now()Math.random() 等方法。

大型应用的 state 和 reducer 体量庞大,Redux 提供 combineReducers 方法,用于将 reducer 拆分。定义的各个子 reducer 可以用这个方法合成为总 reducer 函数。

import { combineReducers } from 'redux';
...
const reducer = combineReducers({
  a: doSomethingWithA,
  b: processB,
  c: c
})

// 等同于
function reducer(state = {}, action) {
  return {
    a: doSomethingWithA(state.a, action),
    b: processB(state.b, action),
    c: c(state.c, action)
  }
}
  • 异步 action => 除开对象类型,action 的值也可以是函数

异步操作不在组件中指明,而是在 Action Creators。异步 action 中一般会调用同步 action,前者直接被 Store 执行,后者传递给 Reducers 执行。

// 组件
handleAsync = () => {
  const {value} = this.handleNumber;
  store.dispatch(createHandleAsyncAction(value*1, 500))
}
// xxx_action.js
export const createHandleAsyncAction = (data, time) => {
  return (dispatch) => { // 异步 action 即返回的不再是对象而是函数
    setTimeout(()=>{
      dispatch(createHandleSyncAction(data))
    }, time)
  }
}

"Error: Actions must be plain objects.Use custom middleware for async actions."

默认传递给 Store 的 action 应该是一个一般对象,否则识别不了。

此时需引入 redux-thunk 中间件,以让 Store 执行函数,不再交给 Reducers。

多个 middleware 可以被组合到一起使用,形成 middleware 链。其中,每个 middleware 都不需要关心链中它前后的 middleware 的任何信息。

import {createStore, applyMiddleware} from 'redux'

react-redux

使用 facebook 官方的插件 react-redux 可以在 React 中更加方便的使用 redux。

  • 连接容器组件和 UI 组件 => react-redux 中的 connect 函数

UI 组件负责页面的呈现和事件的绑定,通常存放于 components 目录。

容器组件负责传递状态以及状态操作的方法,通常存放于 containers 目录。

容器组件并非直接写成一般组件的形式,而是需要借助 connect 函数产生。

/* containers/Xxx */
// 引入 UI 组件
import XxxUI from "../../component/Xxx"
// 引入 connect 连接 UI 组件和 redux
import {connect} from "react-redux"
// 使用 connect 创建并暴露容器组件
export default connect([mapStateToProps], [mapDispatchToProps], [mergeProps], [options])(XxxUI)

连接函数的返回值中传入需要包裹的组件执行时,若没有在引入容器组件的位置给容器组件标签传入 store,则会出现 "Error: Could not find 'store' in the context of 'Connect(Xxx)'" 的报错。

ReactDOM.render(
  <ContainerComponent store={store}>
    <App />
  </ContainerComponent>,
  document.getElementById("root")
);
  • react-redux 中的 connect 作为高阶组件可以传入四个参数
function connect(mapStateToProps?, mapDispatchToProps?, mergeProps?, options?)

容器组件与 UI 组件无法直接通过 props 的形式进行数据的注入(非标签嵌套)。

mapStateToProps => Function 类型的参数,本质是将状态带入视图组件。该函数返回对象中的键值对将分别作为传递给 UI 组件 props 的 key 与 value。

mapStateToProps?: (state, ownProps?) => Object

mapDispatchToProps => Object 或 Function 类型的参数,本质是将操作状态的方法带入 UI 组件。该函数返回对象中的键值对作用同上。

mapDispatchToProps?: Object | (dispatch, ownProps?) => Object

mapDispatchToProps 指定为对象时,其每个字段都是一个 Action Creators。

React-Redux binds the dispatch of your store to each of the action creators using bindActionCreators. The result will be regarded as dispatchProps, which will be either directly merged to connected components, or supplied to mergeProps as the second argument. here.

bindActionCreators(mapDispatchToProps, dispatch)

mapStateToProps 和 mapDispatchToProps 的返回值在内部分别称为 stateProps 和 dispatchProps。

mergeProps => Function 类型的参数,将定义的 stateProps、dispatchProps 和组件自身的 props 传入回调函数,返回的对象将作为 props 传递到被包装的组件。

该属性用于根据组件的 props 对 state 进行筛选,或将 props 中特定数据与 Action Creator 捆绑。

省略该属性时,connect 默认返回 Object.assign({}, stateProps, dispatchProps)。

mergeProps?: (stateProps, dispatchProps, ownProps) => Object

React Redux 相较于 Redux,容器组件默认拥有监测 redux 状态改变的能力,无需在 index 中使用 store.subscribe。

Provider 会自动将 store 注入应用中的容器组件

store 以标签属性的方式添加到容器组件,会存在代码冗余的问题 => 可通过 react-redux 提供的 <Provider /> 组件进行批量注入。

// 优化前
// App.jsx
import React, { Component } from "react";
import {store} from "./redux/store"
import Xxx from "./containers/Xxx/index.jsx";

export default class App extends Component {
  render() {
    return (
      <div>
        <Xxx state={state}/>
        ...
      </div>
    );
  }
}
// 优化后
// index.js
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import store from "./redux/store";
import { Provider } from "react-redux";

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);

整合 UI 组件与容器组件 => 放入一个 jsx

combineReducers 传入的对象就是 Redux 保存的总状态对象。

reducer 中的 preState 会进行浅比较,所以用 push 之流方式不可进行更新页面。此外对数组的改变使用 [newData, ...oldData] 不会破坏 reducer 纯函数的原则。

纯函数:相同输入必定得到相同的输出。必须遵守以下约束:不的改写参数数据;不会产生副作用;不可调用 random now 之流的不纯方法。

有些数据是需要通过异步操作来获取的,但是 Redux 中 dispatch 函数是同步的,也就是说该函数不能直接处理异步操作。此时可以考虑使用 react-redux 中间件,这样就可以在 actionCreator 中编写异步代码,并且返回一个函数而不是一个 action 对象。

The actual implementation of the thunk middleware is very short - only about 10 lines. If you pass a function into dispatch, the thunk middleware sees that it's a function instead of an action object, intercepts it, and calls that function with (dispatch, getState) as its arguments. If it's a normal action object (or anything else), it's forwarded to the next middleware in the chain

redux devtools 需配合项目安装 redux-devtools-extension 库使用

@redux-devtools/extension

import {composeWithDevTools} from 'redux-devtools-extension'
export default createStore(rootReducer, composeWithDevTools(applyMiddleWare(thunk)))

Pinia

Vuex 开发团队的新作品 Pinia,对 Vue2|3 都有很好的支持,也是强推的状态管理库。注意 Pinia 也支持在 Vue-devtools 中进行调试,但需 Vue Devtools v6 版本。

Pinia v2 no longer hijacks Vue Devtools v5, it requires Vue Devtools v6. Find the download link on the Vue Devtools documentation for the beta channel of the extension.

快速开始

  • Advantages

较 Vuex 相比,Pinia 抛弃了 Mutations 操作,保留 State、Getters 和 Actions;不需要嵌套模块,符合 Composition API 和代码扁平化;完整支持 Typescript;可实现代码自动分割。

  • Vite & Pinia & TS
# 搭建项目
npm create vite@latest # vue + ts
# 安装 pinia
npm install pinia
import { createPinia } from 'pinia'
...
app.use(createPinia())
  • API Documentation / pinia / _StoreWithState => Methods

Store 仓库

确保在项目 /src/main.ts 里引入 pinia 后,直接在 /src 目录下新建 store 目录作为状态管理库。

Store 是使用 defineStore() 定义的,需要一个唯一名称作为第一个参数传递,第二个参数是包含 state、getters 和 actions 的对象。

// ?.ts
import { defineStore } from 'pinia'
// useStore => useUser、useCart
export const useStore = defineStore('main',{ // 容器起名 => main
  state:()=>{ return {} }, // SPA 的全局状态
  getters:{}, // 监视或计算状态的变化 => 有缓存的功能
  actions:{} // 操作 state 里数据的业务逻辑
})

store 是在组件的 setup() 中通过调用 xxStore() 进行的实例化,这个对象可以直接访问 state、getters 和 actions 中定义的任何属性与方法。

// ?.vue
import { useStore } from '@/stores/counter' // 接收暴露的 xxxStore
export default {
  setup() {
    const store = useStore()
    ...
    return {
      store, // 可返回整个 store 实例以在模板中使用
    }
  },
}

store 是用 reactive 包裹的对象,故不需要在 getter 之后写 .value;其次是和 setup 中的 props 一样,直接解构失去响应式。

若需在提取属性的同时保持响应式 => 通过 storeToRefs() 传入 store 对象再进行解构。

const store = useStore();
// const {helloPinia,count} = store; // 解构使用失去响应式 -> 放入 reactive({}) 也不行
const {[state01],[state02],[getters01]} = storeToRefs(store);

State 状态

state 被定义为返回初始状态的函数,可通过 store 实例直接读取和写入状态,也可调用 store 上的 $reset() 方法将状态重置到其初始值。

import { defineStore } from 'pinia'
const xxxStore = defineStore('storeId', {
  state: () => { // 推荐使用完整类型推断的箭头函数
    return { // 所有这些属性都将自动推断其类型
      [attribute01]: 0, [attribute02]: 'zs', [attribute03]: true,
    }
  },
})
const store = xxxStore()
store.[attribute01]++
store.$reset()

改变状态除了直接用 store[attribute] 修改,还可以调用 $patch() 方法传入对象或函数。前者适合修改单条数据,后者适合修改多条数据。建议传入函数,因为在使用对象作参数时修改集合(从数组中推送、删除、拼接元素)都需要创建一个新集合。若是业务逻辑导致状态的改变,也可以使用 actions。

// 传入对象 改变 state
store.$patch({ arr: [..., itemNew], ... })
// 传入函数 改变 state
xxxstore.$patch((state) => {
  state.items.push({ name: 'shoes', quantity: 1 });
  state.helloWorld = state.helloWorld === "HelloPinia" ? "HelloWorld" : "HelloPinia";
});
// actions 改变 state
actions: {
  changeState() {
    this.count++,
    (this.helloPinia = this.helloPinia === "helloPinia" ? "Hello World" : "helloPinia");
  }, ...
},

注意在使用 actions 时,不能用箭头函数,因为箭头函数绑定是外部的 this。

若有替换 store 整个状态的需求,可以将 store.$state 属性直接设置为新对象,或通过更改 pinia 实例的 state 来替换应用程序的整个状态,后者在 SSR 期间使用。

store.$state = { counter: 666, name: 'Paimon' }
import { createPinia } from 'pinia'
const pinia = createPinia()
pinia.state.value = {}

可以通过 store 的 $subscribe() 方法查看状态及其变化,类似于 Vuex 的 subscribe 方法。与常规的 watch() 相比,subscriptions 只会在 patches 之后触发一次。

Getters 接收器

Getter 用法类似于 Vue 的计算属性,通常在获取 State 值时进行相应的处理。可以用 defineStore() 中的 getters 属性定义。可接收状态 state 作为首个参数。

export const useStore = defineStore("main", {
  state: () => {
    return {
      ...,
      phone: "12345678910",
    };
  },
  getters: {
    /* 手机中间四位隐藏 */
    // 传入参数可以进行类型推倒出结果 => 写法一 => 不使用箭头函数
    // handleHiddenPhone(state) {
    //   // getters 具有缓存性
    //   console.log("getters");
    //   return state.phone
    //     .toString()
    //     .replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2");
    // },
    // 写法二 => 使用 this => 定义常规函数时
    // handleHiddenPhone(): string {
    //   // getters 具有缓存性
    //   console.log("getters");
    //   return this.phone.toString().replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2");
    // },
    // 写法三 => 接收状态作为第一个参数 -> 使用箭头函数
    handleHiddenPhone:(state) => state.phone.toString().replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2"),
  },
});

getters 有缓存性,虽被多次调用,但是在值不发生改变时只进行读取。在 getters 里可以用 this 进行操作,但项目若使用 TS,那么会因为不传 state 导致 TS 无法自动推断出返回的数据类型,所以要显示标记返回的类型,否则会提示错误。

getters 无法直接被传递任何参数。但可以从 getters 返回一个函数以接受所需的参数。在执行此操作时,getters 不再缓存,只是调用的函数。

import { defineStore } from "pinia";
import { jsokStore } from "./jsok";
export const useStore = defineStore("main", {
  state: () => {
    return {
      ...,
      users: [ { name: "gz", id: 1, }, { name: "hz", id:2 } ],
    };
  },
  getters: {
    ...
    getUserById: (state) => {
      return (userId:number) => state.users.find((user) => user.id === userId);
    },
  },
  actions: {},
});
<template>
  ...
  <div>user 1 => {{getUserById(1)}}</div>
</template>
<script lang="ts">
import { reactive,... } from 'vue'
import { useStore } from '../store/index'
import { storeToRefs } from 'pinia'
export default {
  name: '',
  setup() {
    const store = useStore();
    const {..., getUserById} = storeToRefs(store);
    return {
      ...toRefs(data),
      store,
      ...,getUserById
    }
  },
}
</script>
<style scoped lang='less'></style>

Actions 操作

Actions 相当于组件中的 methods,适合操作业务逻辑,可以通过 defineStore() 中的 actions 属性定义。

和 getters 一样,actions 也能通过 this 访问 store 实例。除了支持异步操作,调用 actions 时,会自动进行类型推断。

import { defineStore } from "pinia";
export const useStore = defineStore("main", {
  state: () => {
    return { helloPinia: "helloPinia", ... };
  },
  getters: {...},
  actions: {
    changeState() { this.helloPinia = this.helloPinia === "helloPinia" ? "Hello World" : "helloPinia"; },
  },
});

xxStore.$onAction() 方法可以订阅 actions 及其结果。传入的参数回调在 actions 之前执行,其中 after() 是在 actions 完成后处理 Promise 的执行函数;onError() 在处理中抛出错误。

// useMain.ts
import { defineStore } from "pinia";
const useMainStore = defineStore("useMainUniqueId", {
  state: () => ({
    user: { name: "zs", age: 23, },
  }),
  actions: {
    subscribeAction(name: string, age: number, manualError?: boolean) {
      return new Promise((resolve, reject) => {
        console.log("subscribeAction Function Performs");
        if (manualError) {
          reject("manualError !");
        } else {
          this.user.name = name;
          this.user.age = age;
          resolve(`${this.user.name} => ${this.user.age}`);
        }
      });
    }
  },
});
export default useMainStore;
<!-- UseMain.vue -->
<template>
  <div>
    <button @click="subscribeNormal">click me show $onAction Ok</button>
    <button @click="subscribeError">click me show $onAction Error</button>
  </div>
</template>
<script lang="ts">
import useMainStore from "../store/useMain";
import { ref, defineComponent, computed } from "vue";
export default defineComponent({
  setup() {
    const useMainUniqueId = useMainStore();
    function subscribeNormal() { useMainUniqueId.subscribeAction(useMainUniqueId.user.name, useMainUniqueId.user.age, false); }
    function subscribeError() { useMainUniqueId.subscribeAction("ErrorError", 97, true); }
    const unsubscribe = useMainUniqueId.$onAction(
      ({
        name, // action 函数的名称
        store, // store 实例 => useMainUniqueId
        args, // action 函数参数数组
        after, // 钩子函数 => 在 action 函数执行完成返回或者 resolves 后执行
        onError, // 钩子函数 => 在 action 函数报错或者 rejects 后执行
      }) => {
        console.log("action func name => ", name);
        console.log("args array => ", args);
        console.log("store instance => ", store);
        after((result) => { console.log("$onAction after func => ", result); });
        onError((error) => { console.log("Error Catch => ", error); });
      },
      false // 卸载组件后不保留
    );
    return { subscribeNormal, subscribeError, };
  },
});
</script>
<style scoped lang="less"></style>

Bugs 解决

  • xxxStore.<function> is not a function => 标识冲突时产生

显示不出组件的情况 => defineStore() 要求首个参数是不重复的字符串唯一标识。

结束

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议,转载请注明出处!

]]>
<![CDATA[Redis]]> 6379]]>https://zairesinatra.github.io//redis/6159ea764716ef1a9649e488Wed, 29 Sep 2021 13:20:00 GMT

快速上手

Redis

NoSQL Not Only SQL 泛指非关系型的数据库,以 Key-Value 存储。Redis 是一款开源的内存数据存储系统,可用作数据库、缓存和消息中间件。其默认具有 16 个数据库,可使用 select [index] 进行切换。

数据库 功能
Memcache NoSql 数据库;内存数据存储系统; 只支持单一类型,不支持持久化,且是多线程与锁的方式
Redis NoSql 数据库;内存数据存储系统; 几乎覆盖了 Memcached 的绝大部分功能; 支持 key-value、持久化;支持多种数据结构; 一般作为缓存数据库辅助持久化的数据库
MongoDB 文档型数据库; 数据存在内存,若内存不足,将非热点数据保存于硬盘; key-value模式;查询功能丰富;支持二进制数据及大型对象; 可替代 RDBMS 成为独立的数据库,或配合 RDBMS 存储特定的数据

Redis 使用单线程与多路 IO 复用技术。多路 IO 复用是指使用一个线程来检查多个文件描述符的就绪状态。就绪则返回,否则阻塞直到超时。得到就绪状态后进行的操作可在同一个线程里执行,也可以启动线程执行。

安装配置

  • Mac 环境
# brew安装
$ brew install redis
# 启动|关闭|重启 redis 服务
$ brew services start|stop|restart redis
# 打开图形化界面
$ redis-cli
# 查看版本信息
127.0.0.1:6379> info
# 开机启动 redis
$ ln -sfv /usr/local/opt/redis/*.plist ~/Library/LaunchAgents
# 配置文件启动 redis-server
redis-server /usr/local/etc/redis.conf
# 停止redis服务
redis-cli shutdown
# redis配置文件位置
/usr/local/etc/redis.conf
# 允许远程访问
# 注释 bind. 默认情况下 redis 不允许远程访问只允许本机
$ vim /usr/local/etc/redis.conf
# redis3.2 后增加 protected-mode, 需把 protected-mode yes 改为 protected-mode no
  • CentOS 环境
# 要求 C 语言编译环境
$ gcc --version
gcc (GCC) 4.8.5 20150623 (Red Hat 4.8.5-44)
# 在 /opt 下载并解压安装包
$ wget http://download.redis.io/releases/redis-7.0.2.tar.gz
$ tar -xzf redis-7.0.2.tar.gz
# Redis 目录下执行编译与安装
$ make
$ make install
# 默认安装目录 /usr/local/bin
$ cd /usr/local/bin && ls
redis-check-aof => 修复有问题的 AOF 文件
redis-cli => 客户端
redis-server => redis 服务器启动命令
redis-benchmark => 性能测试工具
redis-check-rdb => 检查转储数据库文件的完整性
redis-sentinel => 集群使用提供对所有 Redis 节点的监控并在主节点不可用时自动进行故障转移
# redis 启动 => 不推荐前台启动
$ cd /opt/redis-7.0.2/
# 复制配置文件更改配置
$ cp redis.conf /etc/redis.conf
$ cd /etc
# 设置 daemonize no 改为 yes => 搜索模式 / => 309
$ vim redis.conf
# 后续操作只需执行以下即可
$ cd /usr/local/bin
$ redis-server /etc/redis.conf
$ ps -ef | grep redis
root     10261     1  0 13:55 ?        00:00:00 redis-server 127.0.0.1:6379
root     10311  5616  0 13:55 pts/0    00:00:00 grep --color=auto redis
$ redis-cli
127.0.0.1:6379> ping
PONG
# 关闭 redis
127.0.0.1:6379> exit
kill -9 10261
  • Mac 设置 Redis 的密码
# 方式一
$ redis-cli
127.0.0.1:6379> config get requirepass
1) "requirepass"
2) ""
127.0.0.1:6379> config set requirepass "<password>"
OK
127.0.0.1:6379> config get requirepass
(error) NOAUTH Authentication required.
127.0.0.1:6379> auth <password>
OK
127.0.0.1:6379> config get requirepass
1) "requirepass"
2) "<password>"
# 方式二
$ vim /usr/local/etc/redis.conf
# 898 # The requirepass is not compatable with aclfile option and the ACL LOAD
# 899 # command, these will cause requirepass to be ignored.
# 900 #
# 901 # requirepass foobared => 修改掉 foobared 并解除注释
$ brew services restart redis
# 重启后不输入密码可进行 redis-cli 但不能进行操作
$ redis-cli -h 127.0.0.1 -p 6379 -a <password>
# 输入密码登录状态
127.0.0.1:6379> CONFIG get REQUIREPASS
1) "REQUIREPASS"
2) "<password>"
# 不输入密码登录状态
127.0.0.1:6379> CONFIG GET REQUIREPASS
(error) NOAUTH Authentication required.

数据类型

redis 常用的五大数据类型为 String、List、Set、Hash、Zset。

  • Key 键
$ /usr/local/bin/redis-cli
# 设置 key 值与 value
127.0.0.1:6379> set k1 zs # set k2 gz set k3 jr
# 查看当前库所有 key
127.0.0.1:6379> keys *
1) "k3"
2) "k2"
3) "k1"
# exists key => 判断 key 是否存在 -> 1 存在; 0不存在
127.0.0.1:6379> exists k1
(integer) 1
# type key => 查看 key 是什么类型
127.0.0.1:6379> type k1
string
# del key => 删除指定的 key 数据
127.0.0.1:6379> del k3
(integer) 1
# expire key 10 => 为给定的 key 设置过期时间 -- 秒
127.0.0.1:6379> expire k2 10
(integer) 1
# ttl key => 查看还有多少秒过期 -> -1 永不过期; -2 已过期
127.0.0.1:6379> ttl k2
(integer) -2
# unlink key => 根据 value 选择非阻塞删除, 即异步删除
127.0.0.1:6379> unlink k2
(integer) 0
# select => 切换数据库
select 0
# dbsize => 查看当前数据库的 key 数量
127.0.0.1:6379> dbsize
(integer) 1
# flushdb => 清空当前库
127.0.0.1:6379> flushdb
OK
# flushall => 通杀全部库 => 慎用
  • String 字符串

String 是 Redis 最基本的类型,可以理解与 Memcached 一样的类型,一个 Key 对应一个 Value。String 是二进制安全的,即 Redis 的 String 可以包含任何数据,如图片或序列化的对象。Redis 中字符串 Value 最多可以是 512M。

网站页面访问量 PageView PV 可使用 Redis 的 incr、incrby 实现。

# set <Key> <Value> => 设置 Key-Value
127.0.0.1:6379> set k1 v100
OK
127.0.0.1:6379> set k2 v200
OK
# get <Key> => 查询 Key 值
127.0.0.1:6379> get k1
"v100"
127.0.0.1:6379> get k2
"v200"
# append <Key> <Value> => 将给定的 Value 追加到原值末尾
127.0.0.1:6379> append k1 123
(integer) 7
127.0.0.1:6379> get k1
"v100123"
# strlen <Key> => 获取值的长度
127.0.0.1:6379> strlen k1
(integer) 7
# setnx <Key> <Value> => 只有在 Key 不存在的时候设置 Key 值
127.0.0.1:6379> setnx k1 123
(integer) 0
127.0.0.1:6379> setnx k3 v333
(integer) 1
# incr <Key> => 将 Key 值存储的数字增1 -> 如果为空则新增值为1
127.0.0.1:6379> set k4 444
OK
127.0.0.1:6379> incr k4
(integer) 445
# decr <Key> => 将 Key 值存储的数字减1 -> 如果为空则新增值为1
127.0.0.1:6379> decr k4
(integer) 444
# incrby/decrby <Key> <步长> => 将 Key 值存储的数字增减步长
127.0.0.1:6379> incrby k4 10
(integer) 454

原子操作指的是并不会被线程调度机制打断的操作。此操作一旦开始,就一直运行到结束,中间不会有任何 context switch 切换到另一个线程。在单线程中,能在单条指令中完成的操作都可看作为原子操作,因终端只能发生于指令之间。多线程中可能会存在有原子操作,即不被其他进程打断的操作。

面试题:Java 中的 i++ 是否为原子操作?若 i=0,两个线程分别对 i 进行 ++100 操作,结果是?否;Java 是多线程,故 i++ 并非原子操作;最终范围是 2-200。

127.0.0.1:6379> flushdb
OK
127.0.0.1:6379> keys *
(empty array)
# mset <Key> <Value> <Key> <Value>... => 同时设置一个或者多个 Key-Value
127.0.0.1:6379> mset k1 v1 k2 v2 k3 v3
OK
127.0.0.1:6379> keys *
1) "k3"
2) "k2"
3) "k1"
# mget <Key> <Key>... => 同时获取一个或多个 Value
127.0.0.1:6379> mget k1 k2 k3
1) "v1"
2) "v2"
3) "v3"
# msetnx <Key> <Value> <Key> <Value>.. => 同时设置一个或者多个 Key-Value -> 当且仅当所有给定 Key 都不存在
127.0.0.1:6379> msetnx k4 v4 k5 v5
(integer) 1
# getrange <Key> <起始位置> <结束位置> => 获取 Key 的起始位置和结束位置的值
127.0.0.1:6379> set user zairesinatra
OK
127.0.0.1:6379> getrange user 0 4
"zaire"
# setrange <Key> <起始位置> <Value> => 将 Value 的值覆盖起始位置开始
127.0.0.1:6379> setrange user 12 -zszy
(integer) 17
127.0.0.1:6379> get user
"zairesinatra-zszy"
# setex <Key> <过期时间> <Value> => 设置键值的同时设置过期时间
127.0.0.1:6379> setex age 10 22
OK
127.0.0.1:6379> ttl age
(integer) 6
# getset <Key> <Value> => 设置新值同时获得旧值
127.0.0.1:6379> getset user zszy
"zairesinatra-zszy"
127.0.0.1:6379> get user
"zszy"
  • List 列表

在版本 3.2 之前,Redis 列表 List 使用压缩列表 Ziplist 和双向链表 Linkedlist 作为底层实现。在版本 3.2 之后,Redis 采用快速列表 Quicklist,即以压缩列表 Ziplist 为节点的链表 Linkedlist。

在列表元素较少的情况下使用一块连续的内存存储,这个结构是 ziplist,即压缩列表。当数据量比较多的时候会改成 quicklist。因普通链表的附加指针 prev 和 next 空间太大,较为浪费。

127.0.0.1:6379> flushdb
OK
# lpush/rpush <Key> <Value> <Value>... => 从左或右插入一个或者多个值
127.0.0.1:6379> lpush k1 v1 v11 v111
(integer) 3
# lrange key 0 -1 => 获取所有值
127.0.0.1:6379> lrange k1 0 -1
1) "v111"
2) "v11"
3) "v1"
# lpop/rpop key => 从左或者右吐出一个或者多个值 -> 值在键在
127.0.0.1:6379> lpop k1
"v111"
# rpoplpush <Key1> <Key2> => 从 Key1 列表右边吐出一个值插入到 Key2 的左边
127.0.0.1:6379> rpush k2 v2 v22
(integer) 2
127.0.0.1:6379> rpoplpush k1 k2
"v1"
lrange key start stop => 按照索引下标获取元素 -> 从左到右
127.0.0.1:6379> lrange k2 0 -1
1) "v1"
2) "v2"
3) "v22"
# lindex <Key> <Index> => 按照索引下标获得元素
127.0.0.1:6379> lindex k2 0
"v1"
# llen <Key> => 获取列表长度
127.0.0.1:6379> llen k2
(integer) 3
# linsert key before/after value newvalue => 在value的前面插入一个新值
127.0.0.1:6379> linsert k2 before "v22" "newv22"
(integer) 4
127.0.0.1:6379> lrange k2 0 -1
1) "v1"
2) "v2"
3) "newv22"
4) "v22"
# lrem key <n> <Value> => 从左边删除 n 个 Value 值
127.0.0.1:6379> lrem k2 1 newv22
(integer) 1
127.0.0.1:6379> lrange k2 0 -1
1) "v1"
2) "v2"
3) "v22"
# lset key index value => 在列表 Key 中的下标 Index 中修改值 Value
127.0.0.1:6379> lset k2 1 zsv11
OK
127.0.0.1:6379> lrange k2 0 -1
1) "v1"
2) "zsv11"
3) "v22"
  • Set 集合

Redis 的 Set 对外提供的功能与列表 List 类似,但前者可以自动排重。且 Set 提供判断某个成员是否在一个 Set 集合内的重要接口,这个也是 List 所没有的。

Redis 的 Set 是 String 类型的无序集合,其底层是 value 为 null 的 Hash 结构,所有的 value 都指向同一个内部值。故添加,删除,查找的复杂度都是 O (1)。

# sadd <Key> <Value1> <Value2>... => 将一或多个 member 元素添加到集合 Key 中并忽略已存在的 member
127.0.0.1:6379> sadd k1 v1 v2 v3
(integer) 3
# smembers <Key> => 取出该集合的所有值
127.0.0.1:6379> smembers k1
1) "v1"
2) "v3"
3) "v2"
# sismember <Key> <Value> => 判断该集合 Key 是否含有该值
127.0.0.1:6379> sismember k1 v1
(integer) 1
# scard <Key> => 返回该集合的元素个数
127.0.0.1:6379> scard k1
(integer) 3
# srem <Key> <Value> <Value> => 删除集合中的某个元素
127.0.0.1:6379> srem k1 v1 v2
(integer) 2
# spop <Key> => 随机从集合中取出一个元素
127.0.0.1:6379> spop k1
"v3"
# srandmember <Key> n => 随即从该集合中取出 n 个值, 但是不会从集合中删除
srandmember k1 2
1) "v1"
2) "v3"
# smove <Key1> <Key2> <Value> => 将一个集合的某个 Value 移动到另一个集合
127.0.0.1:6379> sadd k2 v3 v4 v5
(integer) 3
127.0.0.1:6379> smove k1 k2 v3
(integer) 1
127.0.0.1:6379> smembers k1
1) "v1"
2) "v2"
127.0.0.1:6379> smembers k2
1) "v5"
2) "v3"
3) "v4"
# sinter <Key1> <Key2> => 返回两个集合的交集元素
127.0.0.1:6379> sadd k3 v4 v6 v7
(integer) 3
127.0.0.1:6379> sinter k2 k3
1) "v4"
# sunion <Key1> <Key2> => 返回两个集合的并集元素
127.0.0.1:6379> sunion k2 k3
1) "v3"
2) "v4"
3) "v5"
4) "v7"
5) "v6"
# sdiff <Key1> <Key2> => 返回两个集合的差集元素 -> Key1 中的不在 Key2 中的
127.0.0.1:6379> sdiff k2 k3
1) "v5"
2) "v3"
  • Hash 哈希

Redis Hash 是一个 String 类型的 field 和 value 的映射表,简单来说就是一个适合存储对象的键值对集合。

# hset <Key> <Field> <Value> => 给 Key 集合中的 Field 键赋值 Value
127.0.0.1:6379> hset user:1001 id 1
(integer) 1
127.0.0.1:6379> hset user:1001 name zs
(integer) 1
# hget <Key1> <Field> => 集合 Field 取出 Value
127.0.0.1:6379> hget user:1001 id
"1"
127.0.0.1:6379> hget user:1001 name
"zs"
# hmset <Key1> <Field1> <Value1> <Field2> <Value2> => 批量设置 Hash 的值
127.0.0.1:6379> hmset user:1002 id 2 name gz
OK
# hexists <Key> <Field> => 查看哈希表 Key 中给定域 Field 是否存在
127.0.0.1:6379> hexists user:1002 name
(integer) 1
# hkeys <Key> => 列出该 Hash 集合的所有 Field
127.0.0.1:6379> hkeys user:1002
1) "id"
2) "name"
# hvals <Key> => 列出该 Hash 集合的所有 Value
127.0.0.1:6379> hvals user:1002
1) "2"
2) "gz"
# hincrby <Key> <Field> increment => 为哈希表 Key 中的域 Field 的值加上增量
127.0.0.1:6379> hincrby user:1002 id 1
(integer) 3
# hsetnx <Key> <Field> <Value> => 将哈希表 Key 中不存在的域 Field 的值设置为 Value
127.0.0.1:6379> hsetnx user:1002 age 22
(integer) 1
127.0.0.1:6379> hkeys user:1002
1) "id"
2) "name"
3) "age"

Hash 类型对应的数据结构是 ziplist 压缩列表、hashtable 哈希表。当 field-value 长度较短且个数较少时,使用 ziplist,否则使用 hashtable。

  • SortedSet Zset 有序集合

Redis zset 有序集合是一个没有重复元素的字符串集合,其内每个成员都关联了一个评分 Score,这个评分 Score 被用来按照顺序排序集合中的成员。成员唯一,但评分可以重复。

zset 底层使用 hash 和跳跃表。前者关联元素 value 和权重 score,保障 value 的唯一性,并能根据 value 找到相应的 score 值。后者目的给 value 排序,根据 score 的范围获取元素列表。

# zadd <Key> <Score1> <Value1> <Score2> <Value2> => 将一或多个 member 元素及其 score 值加入到有序 Key 中
127.0.0.1:6379> zadd topn 2 java 3 cpp 4 node 5 php
(integer) 4
# zrange <Key> start stop (withscores) => 返回有序集key,下标在start与stop之间的元素,带withscores,可以让分数一起和值返回到结果集。
127.0.0.1:6379> zrange topn 0 -1
1) "java"
2) "cpp"
3) "node"
4) "php"
127.0.0.1:6379> zrange topn 0 -1 withscores
1) "java"
2) "2"
3) "cpp"
4) "3"
5) "node"
6) "4"
7) "php"
8) "5"
# zrangebyscore <Key> min max (withscores) => 返回有序集 Key -> 所有 Score 值介于 Min 和 Max 之间的成员 -> 从小到大
127.0.0.1:6379> zrangebyscore topn 3 5
1) "cpp"
2) "node"
3) "php"
# zrevrangebyscore <Key> max min (withscores) =>  返回有序集 Key -> 所有 Score 值介于 Min 和 Max 之间的成员 -> 从大到小
127.0.0.1:6379> zrevrangebyscore topn 5 2
1) "php"
2) "node"
3) "cpp"
4) "java"
# zincrby <Key> increment <Value> => 为元素的 Score 加上增量
127.0.0.1:6379> zincrby topn 5 java
"7"
# zrem <Key> <Value> => 删除该集合下指定值的元素
127.0.0.1:6379> zrem topn php
(integer) 1
# zcount <Key> min max => 统计该集合分数区间内的元素个数
127.0.0.1:6379> zcount topn 2 3
(integer) 1
# zrank <Key> <Value> => 返回该值在集合中的排名 -> 从0开始
127.0.0.1:6379> zrank topn java
(integer) 2
127.0.0.1:6379> zrank topn cpp
(integer) 0

Redis 配置文件

  • Units => 配置的大小单位,只支持 bytes,不支持 bit,大小写不敏感。

  • INCLUDES => 配置文件可作为总闸包含其他文件。

常用于在保持同一主机上的多个实例之间使用相同配置文件,并让每个实例又拥有各自特点的配置。

  • NETWORK

bind=127.0.0.1 => 只接收本机的访问请求,不写的情况下无限制接收任何地址的访问。生产环境中常将其注释。

protect-mode => 没有设定 bind ip 与密码时,只允许接收本机的响应。

backlog => 连接队列。其队列总和 = 未完成三次握手队列 + 已完成三次握手队列。高并发环境应提高此值以避免客户端连接的速度问题。

通常 /proc/sys/net/core/somaxconn 值是固定的 128,高并发情况下应增大 /proc/sys/net/core/somaxconn & /proc/sys/net/ipv4/tcp_max_syn_backlog。

timeout 300 => 当客户端闲置指定时间后关闭连接 -> 0 表示关闭该功能。

  • GENERAL

daemonize => 后台守护进程;pidfile => 存放进程号文件的位置,每个实例的产生都不同。以守护进程方式运行时,默认会把 pid 写入 /var/run/redis.pid 文件,可指定 pidfile /var/run/redis.pid。

  • LIMITS

maxclients 设置 Redis 同时可与多少客户端进行连接,默认 10000 个客户端。若达到此限制,redis 则会拒绝新的连接请求,并且向连接请求方发出 max number of clients reached 以作回应。

maxmemory 指定 Redis 最大内存限制,建议必须设置,否则内存占满会造成服务器宕机。若达到 Redis 可使用内存上限,则会试图移除内部数据,移除规则可通过 maxmemory-policy 指定。

Maxmemory-policy
(1)volatile-lru => 使用 LRU 算法移除 Key(只针对设置了过期时间的键)
(2)allkeys-lru => 使用 LRU 算法移除 Key
(3)volatile-random => 随机移除过期集合中的 Key
(4)allkeys-random => 随机的移除 Key
(5)volatile-ttl => 移除 TTL 值最小的 Key,即那些最近要过期的 Key
(6)noeviction => 不进行移除。针对写操作,只返回错误信息

Maxmemory-samples 设置样本数量。一般设置 3-7 的数字,虽然样本越小越不精确,但是性能消耗更小。

  • REPLICATION

slaveof <masterip> <masterport> 设置当本机为 slav 服务时,设置 master 服务的 IP 地址及端口,在 Redis 启动时自动从 master 进行数据同步。在 RedisV7 中改成 replicaof <masterip> <masterport>。

masterauth <master-password> 是当 master 服务设置了密码保护时,slav 服务连接master 的密码。

# masterauth <master-password>
  • SECURITY

requirepass foobared 设置 Redis 连接密码。如果配置了连接密码,客户端在连接时需要通过 AUTH <password> 命令提供密码,默认关闭。

# requirepass foobared
  • ADVANCED CONFIG

activerehashing yes 指定是否激活重置哈希,默认开启。

hash-max-listpack 指定在超过一定的数量或最大的元素超过某一临界值时,采用一种特殊的哈希算法。旧版本是 hash-max-zipmap。

# Hashes are encoded using a memory efficient data structure when they have a
# small number of entries, and the biggest entry does not exceed a given
# threshold. These thresholds can be configured using the following directives.
hash-max-listpack-entries 512
hash-max-listpack-value 64

发布和订阅

Redis 具备发布订阅功能,但是其主要任务还是分布式的缓存,因此这种订阅发布常由专门的 kafka、activemq 等消息中间件来完成。

# 视窗一
$ /usr/local/bin/redis-cli
# 订阅频道
127.0.0.1:6379> SUBSCRIBE channel1
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "channel1"
3) (integer) 1
1) "message"
2) "channel1"
3) "helloChannel1"
# 视窗二
# 发布信息
PUBLISH channel1 helloChannel1
(integer) 1

Redis6 新数据类型

BitMaps

BitMaps 本身不是一种数据类型,而是字符串 Key-Value,可以实现对字符串的位进行操作。类似 "abc" 字符串是三个字节,每个字节 8 位,在计算机中通过 ASCII 码 97、98、99 转换为对应二进制 01100001、01100010、01100011。通过合理的操作位提高内存使用和开发效率。

BitMaps 可视作一个以位为单位的数组,数组中每个单元只存储 0 和 1,数组下标为偏移量。setbit <Key> <Offset> <Value> 设置 bitmaps 中某个偏移量的值 0 或 1。getbit <Key> <Offset> 获取 bitmaps 中某个偏移量的值。bitcount <Key> [start end] 统计字符串从 start 到 end 被设置为 1 的 bit 数。多 bitmaps 集合运算 bitop and|or|not|xor <DestKey> [Key...] 结果保存 DestKey。

常应用将每个独立用户是否访问过网站存放在 BitMaps,将访问的用户记作 1,没有访问的用户记作 0,偏移量作为用户 id。

需注意在开发中用户 id 常以指定数字开头,若直接将用户 id 和 bitmaps 的偏移量对应会造成浪费,通常做法是每次做 setbit 操作时将用户 id 减去这个指定数字。

# 现有20个用户 => 其中 1 6 11 15 19 于 1 月访问网站 => 对 bitmap 进行初始化
127.0.0.1:6379> setbit users:202101 1 1
(integer) 0
127.0.0.1:6379> setbit users:202101 6 1
(integer) 0
127.0.0.1:6379> setbit users:202101 11 1
(integer) 0
127.0.0.1:6379> setbit users:202101 15 1
(integer) 0
127.0.0.1:6379> setbit users:202101 19 1
(integer) 0
127.0.0.1:6379> getbit users:202101 1
(integer) 1
127.0.0.1:6379> bitcount users:202101
(integer) 5
# 日期限定访问网站的相同人人数
setbit unique:users:20201101 1 1
setbit unique:users:20201101 2 1
setbit unique:users:20201101 5 1
setbit unique:users:20201101 9 1
setbit unique:users:20201102 0 1
setbit unique:users:20201102 1 1
setbit unique:users:20201102 4 1
setbit unique:users:20201102 9 1
bitop and unique:users:and:20201101_02 unique:users:20201102 unique:users:20201101
(integer) 2
127.0.0.1:6379> bitcount unique:users:and:20201101_02
(integer) 2

若网站有一亿用户,每天独立访问的用户有五千万,那么每天用集合和 Bitmaps 分别存储活跃用户对比。

数据类型 每个用户 id 占用空间 需要存储的用户量 全部内存量
集合类型 64位 50000000 64位*50000000=400MB
Bitmaps 1位 100000000 1位*100000000=12.5MB

HyperLogLog

独立访客 UniqueVisitor UV、独立 IP 数、搜索记录等需要去重和计数的问题称为基数问题。虽说 Mysql 能使用 distinct 和 count 处理;Redis 能提供 Hash、Set 和 BitMaps,但随着数据增加,占用空间增大,数据集显得有些捉襟见肘。

HyperLogLog 是适用于做基数统计的算法。每个 HyperLogLog 键只需花费 12 KB 内存,就可计算 2^64 个不同元素的基数。在输入元素数量或体积巨大时,计算基数所需空间是固定有限的。因其只据输入元素计算基数而不会存储输入元素本身,故不能像集合般返回输入的各个元素。

数据集 {1,3,5,7,5,7,8} 中的基数集为 {1,3,5,7,8},基数,即不可重复元素为 5。基数估计就是在误差可接收范围内快速计算基数。

pfadd <Key> <Element> [Element...] 添加指定元素到 HyperLogLog。近似基数变化返回 1,否则返回 0。pfcount <Key> [key...] 计算 HLL 近似基数。pfmerge <DestKey> <SourceKey> [SourceKey] 将一个或多个 HLL 合并后的结果存储于另一个 HLL。

127.0.0.1:6379> pfadd program "java"
(integer) 1
127.0.0.1:6379> pfadd program "node"
(integer) 1
127.0.0.1:6379> pfadd program "javascript"
(integer) 1
127.0.0.1:6379> pfadd program "java"
(integer) 0
127.0.0.1:6379> pfadd program "java" "cpp" "c"
(integer) 1
127.0.0.1:6379> pfcount program
(integer) 5
127.0.0.1:6379> pfadd language "java" "node" "javascript" "cpp"
(integer) 1
127.0.0.1:6379> pfadd language "java" "node" "javascript" "cpp"
(integer) 0
127.0.0.1:6379> pfmerge mergetable program language
OK
127.0.0.1:6379> pfcount mergetable
(integer) 5

Geospatial

Redis 提供对地理信息支持,Geospatial 提供与地理空间索引相关的命令。

geoadd <Key> <Longitude> <Latitude> <Member> [...] 添加地理位置。

geopos <Key> <Member> [Member...] 获得指定地区的坐标值。

geodist <Key> <Member1> <Member2> [m|km|ft|mi] 获取位置直线距离。

georadius <Key> <Longitude> <Latitude> radius m|km|ft|mi 以给定的经纬度为中心,找出某一半径内的元素。可作为微信附近的人实现方法。

127.0.0.1:6379> geoadd china:city 112.93 28.23 changsha 114.05 22.52 shenzhen
(integer) 2
127.0.0.1:6379> geopos china:city changsha
1) 1) "112.92999833822250366"
   2) "28.2299993949683099"
127.0.0.1:6379> geodist china:city changsha shenzhen
"644984.9761"
127.0.0.1:6379> georadius china:city 111 20 1000 KM
1) "shenzhen"
2) "changsha"

Jedis

Jedis 是 redis 的客户端工具,即通过 Java 操作 redis。类似于 JDBC 通过 Java 操作数据库。

普通工程使用

  • Jedis 的依赖 jar 包
<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.13.2</version>
    <scope>compile</scope>
</dependency>
  • 注意事项 => 关闭防火墙;bind 配置应注释;protected-mode no
### --- shell --- ###
# 禁用 Linux 的防火墙
systemctl status firewalld
systemctl stop/disable firewalld.service
### --- redis.conf --- ###
# bind 配置应注释 => 不只接受本机的访问请求
# bind=127.0.0.1
# protected-mode 从默认的 yes 改为 no
protected-mode no
  • Jedis 常用操作
public class JedisDemo01 {
    public static void main(String[] args) {
        // 创建 jedis
        Jedis jedis = new Jedis("<yourIP>",6379);
        // 测试
        String ping = jedis.ping();
        System.out.println(ping);
    }
    // 操作 key
    @Test
    public void demo01(){
        Jedis jedis = new Jedis("<yourIP>",6379);
        jedis.set("user", "zs");
        Set<String> keys = jedis.keys("*");
        for(String key : keys){
            System.out.println(key);
        }
        System.out.println(jedis.exists("user"));
        System.out.println(jedis.ttl("user"));
        System.out.println(jedis.get("user"));
    }
    // 设置多个 key-value 操作 String
    @Test
    public void demo02(){
        Jedis jedis = new Jedis("<yourIP>",6379);
        jedis.mset("str1","v1","str2","v2","str3","v3");
        System.out.println(jedis.mget("str1","str2","str3"));
        jedis.flushDB();
    }
    // 操作 list
    @Test
    public void demo03(){
        Jedis jedis = new Jedis("<yourIP>",6379);
        jedis.lpush("key1","zs","gz","hz","jr","ss");
        System.out.println(jedis.lrange("key1", 0, -1));
        jedis.flushDB();
    }
    // 操作 set
    @Test
    public void demo04(){
        Jedis jedis = new Jedis("<yourIP>",6379);
        jedis.sadd("key1","zs","gz","hz","jr","ss");
        System.out.println(jedis.smembers("key1"));
        jedis.flushDB();
    }
    // 操作 hash
    @Test
    public void demo05(){
        Jedis jedis = new Jedis("<yourIP>",6379);
        jedis.hset("users","name","zs");
        System.out.println(jedis.hget("users","name"));
        jedis.flushDB();
    }
    // 操作 zset
    @Test
    public void demo06(){
        Jedis jedis = new Jedis("<yourIP>",6379);
        jedis.zadd("china",100,"cs");
        System.out.println(jedis.zrange("china",0,-1));
        jedis.flushDB();
    }
}
  • 手机验证码功能

要求输入手机号,点击发送后随机生成 6 位数字码,2 分钟有效;输入验证码,点击验证,返回成功或失败;每个手机号每天只能输入 3 次。

public class JedisDemo02 {
    public static void main(String[] args) {
        // 模拟验证码发送
//        verifyCode("12345678987");

        // 模拟验证码校验
         getRedisCode("12345678987","809507");
    }

    // 3 验证码校验
    public static void getRedisCode(String phone,String code) {
        //从redis获取验证码
        Jedis jedis = new Jedis("xxx.xx.xx.xxx",6379);
//        jedis.auth();
        // 验证码key
        String codeKey = "VerifyCode"+phone+":code";
        String redisCode = jedis.get(codeKey);
        // 判断
        if(redisCode.equals(code)) {
            System.out.println("成功");
        }else {
            System.out.println("失败");
        }
        jedis.close();
    }

    // 2 每个手机每天只能发送三次,验证码放到redis中,设置过期时间120
    public static void verifyCode(String phone) {
        // 连接redis
        Jedis jedis = new Jedis("xxx.xx.xx.xxx",6379);

        // 拼接key
        // 手机发送次数key
        String countKey = "VerifyCode"+phone+":count";
        // 验证码key
        String codeKey = "VerifyCode"+phone+":code";

        // 每个手机每天只能发送三次
        String count = jedis.get(countKey);
        if(count == null) {
            // 没有发送次数,第一次发送
            // 设置发送次数是1
            jedis.setex(countKey,24*60*60,"1");
        } else if(Integer.parseInt(count)<=2) {
            // 发送次数+1
            jedis.incr(countKey);
        } else if(Integer.parseInt(count)>2) {
            // 发送三次,不能再发送
            System.out.println("今天发送次数已经超过三次");
            jedis.close();
            return;//超过三次之后就会自动退出不会再发送了,不添加这一行,即使显示发送次数,但还会有验证码接收到
        }

        // 发送验证码放到 redis 里面
        String vcode = getCode(); // 调用生成的验证码
        jedis.setex(codeKey,120,vcode); // 设置生成的验证码只有120秒的时间
        jedis.close();
    }

    // 1 生成6位数字验证码,code是验证码
    public static String getCode() {
        Random random = new Random();
        String code = "";
        for(int i=0;i<6;i++) {
            int rand = random.nextInt(10);
            code += rand;
        }
        return code;
    }
}

SpringBoot 整合 redis

  • 在 pom.xml 文件中引入 redis 相关依赖
<!-- redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- spring2.X 集成 redis 所需 common-pool2-->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
    <version>2.11.1</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.13.3</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-core</artifactId>
    <version>2.13.3</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
  • application.properties 配置 redis
# Redis 服务器地址
spring.redis.host=<yourIP>
# Redis 密码
spring.redis.password=<yourPassword>
# Redis 服务器连接端口
spring.redis.port=6379
# Redis 数据库索引
spring.redis.database= 0
# 连接超时时间 => ms
spring.redis.timeout=1800000
# 连接池最大连接数 => 负值表示没有限制
spring.redis.lettuce.pool.max-active=20
# 最大阻塞等待时间 => 负数表示没限制
spring.redis.lettuce.pool.max-wait=-1
# 连接池中的最大空闲连接
spring.redis.lettuce.pool.max-idle=5
# 连接池中的最小空闲连接
spring.redis.lettuce.pool.min-idle=0
  • 添加 redis 配置类
@EnableCaching
@Configuration
public class RedisConfig extends CachingConfigurerSupport {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        RedisSerializer<String> redisSerializer = new StringRedisSerializer();
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        template.setConnectionFactory(factory);
        // key 序列化方式
        template.setKeySerializer(redisSerializer);
        // value 序列化
        template.setValueSerializer(jackson2JsonRedisSerializer);
        // value hashmap 序列化
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        return template;
    }

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        RedisSerializer<String> redisSerializer = new StringRedisSerializer();
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        // 解决查询缓存转换异常
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        // 配置序列化 => 解决乱码 => 过期时间 600 秒
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(600))
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
                .disableCachingNullValues();
        RedisCacheManager cacheManager = RedisCacheManager.builder(factory)
                .cacheDefaults(config)
                .build();
        return cacheManager;
    }
}
  • RedisTestController 中添加测试方法
@RestController
@RequestMapping("/redisTest")
public class RedisTestController {
    @Autowired
    private RedisTemplate redisTemplate;

    @GetMapping
    public String testRedis() {
        // 设置值
        redisTemplate.opsForValue().set("name","zs");
        // 获取值
        String name = (String)redisTemplate.opsForValue().get("name");
        return name;
    }
}

事务和锁机制

Redis 事务

Redis 事务允许在一个步骤中执行一组命令。

  • 属性一 => 事务中的所有命令作为单个隔离操作按顺序执行。另一个客户端发出的请求不可能在 Redis 事务的执行过程中得到处理。
  • 属性二 => Redis6 之前的命令操作是原子性的,因为操作是单线程的。原子性意味着要么所有命令都被处理,要么不被处理。若有多条命令并发执行,那么就不一定是原子性的。

Redis 事务由命令 MULTI 发起,然后需传递应在事务中执行的命令列表,之后整个事务由 EXEC 执行。在组队的过程中可以通过 DISCARD 来放弃组队。

127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> set k1 v1
QUEUED
127.0.0.1:6379(TX)> set k2 v2
QUEUED
127.0.0.1:6379(TX)> exec
1) OK
2) OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> set key1 value1
QUEUED
127.0.0.1:6379(TX)> set key2 value2
QUEUED
127.0.0.1:6379(TX)> discard
OK

事务错误处理

  • 若组队中某命令出现错误,执行时整个队列都会被取消。
  • 若执行阶段某命令出现错误,则只有报错的命令不会被执行,其他命令继续执行,不会回滚。

事务冲突

  • 悲观锁 Pessimistic Lock

悲观锁对数据是否被外界修改持保守态度,因此在整个数据处理过程中,将数据处于锁定状态。传统的关系型数据库里用到了很多这种锁机制,比如行锁、表锁、读锁,写锁等,都是在做操作之前先上锁。

  • 乐观锁 Optimistic Lock => 抢红包 淘宝抢购 秒杀

乐观锁会假设数据一般情况下不会因修改而造成冲突,只在数据进行提交更新时才会正式对数据的冲突与否进行版本号检测。乐观锁利用 Check-and-Set 机制实现事务,适用于多读的应用类型以提高吞吐量。

WATCH 可监视指定键,在事务执行前指定键被其他命令改动,那么事务执行将被打断并通知事务失败。UNWATCH 可取消 WATCH 命令对指定键的监视。

# window1
127.0.0.1:6379> set balance 100
OK
127.0.0.1:6379> watch balance
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> incrby balance 20
QUEUED
127.0.0.1:6379(TX)> exec
(nil)
127.0.0.1:6379> get balance
"110"
# window2
127.0.0.1:6379> keys *
1) "balance"
127.0.0.1:6379> watch balance
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> incrby balance 10
QUEUED
127.0.0.1:6379(TX)> exec
1) (integer) 110

Redis 持久化

AOF 表示附加文件,是更改日志样式的持久化。RDB 用于 Redis 数据库文件,是快照风格的持久化。

RDB Redis DataBase

以指定的时间间隔将内存中的数据集快照写入磁盘,即 SnapShot 快照,其恢复时是将快照文件直接读到内存里。内存持久化后在启动路径生成文件 dump.rdb。同步之前会先建立临时文件并放入数据,此后才替换主要内容。

注意当未持久化的最新一次时间间隔内,若对数据进行操作的同时服务器宕机,那么会造成数据丢失。

  • RDB 持久化流程 => 单独 fork 一个子进程进行持久化

首先将数据写入一个临时文件,待持久化过程结束再用这个临时文件替换掉上次持久化的文件。在此过程中,主进程不进行任何的 IO 操作。

如需大规模数据恢复,且对数据恢复的完整性不敏感,那么 rdb 会比 aof 方式更加高效。其缺点就是最后一次持久化的数据可能丢失。

Fork
In an operating system, a fork is a Unix or Linux system call to create a new process from an existing running process. The new process is a child process of the calling parent process.

  • SNAPSHOTTING

stop-writes-on-bgsave-error => 无法写入磁盘的时候(磁盘空间满了),不再进行 redis 的写操作。

BGSAVE =>
Redis BGSAVE command saves the DB in the background. The OK code is immediately returned. Redis forks, the parent continues to serve the clients, the child saves the DB on the disk, then exits. A client may be able to check if the operation succeeded using the LASTSAVE command.

rdbcompression => 是否对持久化的文件进行压缩。yes 则采用 LZF 算法压缩。

rdbchecksum => 存储快照后以 CRC64 Cyclic redundancy check 进行数据校验。

save => 设置某段时间内存在多少变化就会进行持久化操作。

AOF Append Of File

以日志的形式记录写操作(增量保存),将执行过的所有写指令保存,只许追加但不可以改写文件,redis 启动时会读取该文件并执行以重构恢复数据。

  • AOF 持久化流程

AOF 默认不开启,需在配置文件设置 appendonly 为 yes。其文件保存路径与 RDB 一致。当 AOF 与 RDB 同时开启,系统会默认读取前者数据(完整性)。

  • APPEND ONLY MODE

appendonly no => 是否在每次更新操作后进行日志记录。

appendfilename appendonly.aof => 指定更新日志文件名。

appendfsync => 更新日志条件|同步频率设置 -> no|fsync|everysec。

  • 异常恢复
/usr/local/bin/redis-check-aof--fix appendonly.aof

启动重写将创建当前仅附加文件的小型优化版本。从 Redis 2.4 开始,AOF 重写由 Redis 自动触发,但是该 BGREWRITEAOF 命令可用于随时触发重写。

no-appendfsync-on-rewrite=yes => 不写入 aof 文件只写入缓存。

auto-aof-rewrite-percentage => 设置重写的基准值,文件达到 100% 时重写。

auto-aof-rewrite-min-size => 设置重写的基准值,文件达到 64MB 开始重写。

AOF 当前大小 >= base_size + base_size*100% 且 >= 64mb 的情况下开启重写。

小结比较

推荐两个都启用。若对数据不敏感,可单独用 RDB,但不建议单独用 AOF。如果只是做纯内存缓存,可以都不用。

主从复制

主从复制即主机数据更新后根据配置和策略,自动同步到从机的 Master/Slaver 机制。Master 以写为主,Slave 以读为主。通过读写分离提高性能的扩展以及容灾的快速恢复。

快速配置

为启动多个 redis 服务,将 redis.conf duplicator 在修改配置后放入新建 myredis 目录,用于带配置文件的启动。daemonize 应采用 yes 以保证 redis 在后台运行。

  • 通用配置
[centos ~]# /usr/local/bin/redis-server /etc/redis.conf
[centos ~]# mkdir /myredis && cd /myredis
[centos myredis]# cp /etc/redis.conf /myredis/redis.conf
[centos myredis]# ls
redis.conf
# /myredis/redis.conf
...
appendonly no # 是否在每次更新操作后进行日志记录 => 默认情况下是异步的把数据写入磁盘
  • redis63Xx.conf 独立配置
# redis63XX.conf
include /myredis/redis.conf # 绝对路径
pidfile /var/run/redis_6379.pid # redis 以守护进程方式运行时默认把 pid 写入 /var/run/redis[???].pid 文件
port 6379
dbfilename dump6379.rdb # 一段时间自动对数据库遍历并将内存快照写入 dump.rdb
  • 运行情况查看
[centos myredis]# redis-server redis6379.conf
[centos myredis]# redis-server redis6380.conf
[centos myredis]# redis-server redis6381.conf
[centos myredis]# ps -ef | grep redis
root     15665     1  0 Jun16 ?        00:01:19 redis-server *:6379
root     25595     1  0 21:24 ?        00:00:00 redis-server *:6380
root     25606     1  0 21:24 ?        00:00:00 redis-server *:6381
root     25706 21513  0 21:25 pts/0    00:00:00 grep --color=auto redis
[centos myredis]# kill -9 15665 25595 25606
  • 新终端连接
[centos myredis]# redis-cli -p 6379
[centos myredis]# redis-cli -p 6380
[centos myredis]# redis-cli -p 6381
[centos myredis]# info replication # 查看复制实例信息
# Replication
role:master
connected_slaves:0
...

一主二仆

从服务器宕机后重启,那么这个服务器不再作为主从中的从服务器,而是独立于原主服务器的主服务器。主服务器宕机后从服务器仍等待主机,待主服务器恢复后还是主服务器,数据依旧。当从服务器连接主服务器,从服务器会向主服务器发送数据同步的消息。主服务器接到同步消息后,会将主服务器数据进行持久化操作,并将产生的 dump.rdb 文件发送给从服务器读取。每次主服务器进行写操作后,都会和从服务器进行数据同步。

  • 在从机上执行 slaveof 命令,设置其为指定主机的从机
# 设置为指定主机的从机 => slaveof <ip> <port>
127.0.0.1:63xx># slaveof 127.0.0.1 6379
OK

简而言之,从服务器宕机后重启再作为主服务器的从机,那么还是会拿到主机中的所有数据,哪怕在宕机期间主机进行了操作。主服务器宕机后不会被从服务器夺取主服务器的位置,且在主服务器恢复后数据依旧保留。

  1. 主机不配置,从机使用 slaveof 或 replicaof 声明所属主机。
  2. 主机如果宕机,重启后自动恢复到之前的转态,不需要再做其他任何修改,再新增加数据,从机可以读到数据。
  3. 从机如果宕机,再次重启后,再次读数据,读不到。需要使用 slaveof 或 replicaof 再次声明所属主机,声明之后可以再次读取数据。
  4. 主机可写可读,从机只可以读,不可以写。
  5. 从机使用 slaveof 或 replicaof 声明所属主机时会发送 sync 到主机,获取主机的 rdb 文件并执行,以此实现数据同步。后续增加数据,使用增量复制完成同步。如果是宕机后再次声明所属主机,则使用全量复制完成同步。

薪火相传

因为从机可以接收来自其他从机的连接和同步请求,那么前一个从机可以是后一个从机的主机。薪火相传这种模式可以有效减轻主机的写压力,通过去中心化降低风险。需要注意的是,若中途变更转向,那么会清除此前的数据,并重新建立最新的拷贝。风险在于一旦某个从机宕机,后边的从机都没法备份。主机宕机,从机还是从机,只是无法写数据。这种模式常见于项目体量的增加,需要树状分布的管理。

反客为主

反客为主即在主服务器宕机后,从服务器升级为主服务器,其后的从服务器不用做任何修改。

# 从机恢复主机状态
slaveof no one

哨兵模式 sentinel

哨兵模式作为反客为主的自动版,能够在后台监视主机的故障与否,并根据投票自动的将从机转换为主机。在原主机从宕机状态恢复后,会默认变成新主机的从机。

新主机会在一众从主机中产生,从机的挑选顺序是根据优先级靠前、偏移量最大或 runid 最小的指标来进行选择。

优先级在 redis.conf 中默认为 slave|replica-priority 100,值越小优先级越高。
偏移量是指获得原主机数据最全的。
每个 redis 实例启动后都会随机生成一个 40 位的 runid。

哨兵模式存在复制延时的缺点。因所有的写操作都是先在主机,然后同步更新到从机,同步过程会有一定的延迟。当系统繁忙时,或者从机数量较大时,延迟问题可能会更加严重。

  • 配置 sentinel.conf
# mymaster => 监控对象起的服务器名
# 1 => 至少有一个哨兵同意迁移的数量
sentinel monitor mymaster 127.0.0.1 6379 1
  • 启动哨兵

哨兵默认启动在 26379 端口,在主服务器宕机后一段时间会打印操作日志并切换丛机为主机。

[centos myredis]# redis-sentinel  /myredis/sentinel.conf
private static JedisSentinelPool jedisSentinelPool=null;
public static  Jedis getJedisFromSentinel(){
    if(jedisSentinelPool==null){
        Set<String> sentinelSet=new HashSet<>();
        sentinelSet.add("remoteIp:26379");
        JedisPoolConfig jedisPoolConfig =new JedisPoolConfig();
        jedisPoolConfig.setMaxTotal(10); // 最大可用连接数
        jedisPoolConfig.setMaxIdle(5); // 最大闲置连接数
        jedisPoolConfig.setMinIdle(5); // 最小闲置连接数
        jedisPoolConfig.setBlockWhenExhausted(true); // 连接耗尽是否等待
        jedisPoolConfig.setMaxWaitMillis(2000); // 等待时间
        jedisPoolConfig.setTestOnBorrow(true); // 取连接的时候进行一下测试 ping pong
        jedisSentinelPool=new JedisSentinelPool("mymaster",sentinelSet,jedisPoolConfig);
        return jedisSentinelPool.getResource();
        } else {
        return jedisSentinelPool.getResource();
    }
}

集群

集群模式常用于扩容以及主机并发写操作压力的分摊。当主从模式和薪火相传模式中的主机宕机,可通过无中心化的集群配置解决地址变化导致的配置修改问题。集群实现水平扩容,即启动多个服务节点,并将整个数据库分布存储在这些节点,每个节点存储总数据的 1/N。

删除持久化数据 => 将 rdb、aof 文件都删除掉。

集群配置

  • 单服务器设置六项服务模拟集群,操作前应先将 dump.rdb 删除排除干扰
# 9 结尾表示主机 0 结尾表示从机
rm -rf dump63*
  • redis cluster 配置修改 => redis63xx.conf
include /home/bigdata/redis.conf
port 6379
pidfile "/var/run/redis_6379.pid"
dbfilename "dump6379.rdb"
dir "/home/bigdata/redis_cluster"
logfile "/home/bigdata/redis_cluster/redis_err_6379.log"
cluster-enabled yes # 打开集群模式
cluster-config-file nodes-6379.conf # 设定节点配置文件名
cluster-node-timeout 15000 # 设定节点失联时间 => 超过该时间(毫秒)集群自动进行主从切换
  • 复制多个配置文件并修改
# 复制配置文件
cp redis6379.conf redis63xx.conf
# 替换匹配字符
:%s/6379/63xx
  • 启动服务|合成集群 => redis 实例启动且 nodes-xxxx.conf 文件正常生成
redis-server redis63xx.conf * 6
ps -ef | grep redis # 此时应该有 6 个启动的 redis 项目
cd /opt/redis-7.0.2/src # 进入生成集群的环境
# 此处不要用 127.0.0.1 应用真实 IP 地址
# --replicas 1 => 简单的方式配置集群 -> 一台主机和一台从机 -> 正好三组
redis-cli --cluster create --cluster-replicas 1 xxx.xx.xx.xxx:6379 xxx.xx.xx.xxx:6380 xxx.xx.xx.xxx:6381 xxx.xx.xx.xxx:6389 xxx.xx.xx.xxx:6390 xxx.xx.xx.xxx:6391

集群操作与故障恢复

  • 节点分配

一个集群至少要有三个主节点。--cluster-replicas 1 表示希望为集群中的每个主节点创建一个从节点。分配原则尽量保证每个主数据库运行在不同的地址,每个从库和主库不在一个地址上。

  • slots 插槽

redis 集群包含 16384 个插槽,数据库中的每个键都属于这些插槽中的单元,公式 CRC16(key) % 16384 用于计算键 key 属于哪个插槽。

redis 集群中的每个节点都负责处理一部分的插槽。若集群有主节点 A、B、C,那么可以分配 A 处理 0 至 5460 号插槽;B 处理 5461 至 10922 号插槽;C 处理余下至 16383 号的插槽。

  • 集群中录入值
# 集群方式连接
[centos myredis]# redis-cli -c -p 6379
127.0.0.1:6379> set k1 v1
-> Redirected to slot [12706] located at xxx.xx.xx.xxx:6381
OK
xxx.xx.xx.xxx:6381> set k2 v2
-> Redirected to slot [449] located at xxx.xx.xx.xxx:6389
OK
# 根据组名计算插槽做添加 => 使 key 中 {} 内相同内容的键值对放到一个 slot
xxx.xx.xx.xxx:6389> mset name zs age 22 address cs
(error) CROSSSLOT Keys in request don't hash to the same slot
xxx.xx.xx.xxx:6389> mset name{user} zs age{user} 22 address{user} cs
OK
  • 查询集群中的值
# 计算某键在哪个插槽
xxx.xx.xx.xxx:6389> cluster keyslot k1
(integer) 12706 # k1 插槽值
# 计算插槽值中有几个键
xxx.xx.xx.xxx:6389> cluster countkeysinslot 449
(integer) 1
# 返回 count 个 slot 槽中的键
xxx.xx.xx.xxx:6389> cluster getkeysinslot 5474 10
1) "address{user}"
2) "age{user}"
3) "name{user}"
  • 故障恢复

主节点下线后,从节点会自动升为主节点。主节点从宕机恢复后会变为从节点。当某一段插槽的主从节点都宕掉,服务会根据配置产生全部宕机或只有该插槽数据罢工的两种不同情况。

# redis.conf
cluster-require-full-coverage yes|no
  • 集群的 Jedis 开发
public class JedisClusterTest {
  public static void main(String[] args) {
     // 创建对象 => 无中心化 -> 放一个 HostAndPort 即可
     HostAndPort hostAndPort = new HostAndPort("xxx.xx.xx.xx",6379);
     JedisCluster jedisCluster=new JedisCluster(hostAndPort);
     jedisCluster.set("k1", "v1");
     System.out.println(jedisCluster.get("k1"));
     jedisCluster.close();
  }
}

应用问题

缓存穿透

缓存穿透的现象是应用服务器压力增大,缓存服务器虽平稳运行但命中率下降,总是会进行数据库查询。通常情况下是由于缓存本就查询不到数据库中不存在的内容或者出现非正常的地址访问造成的。比如连续访问不存在的百度文库地址。

对于这种问题可以考虑进行空值缓存,即查询返回的数据为空时,仍然把这个空结果进行缓存,并设置最长不超过五分钟的短过期时间。同时建议设置可访问的白名单,即名单 Id 作为 bitmaps 的偏移量,每次访问都和其中 Id 进行比较,若不在其中,那么就进行拦截,不允许访问。其他方案是采用布隆过滤器 Bloom Filter,其底层是一个二进制向量和一系列的随机映射函数,但是要考虑其命中率的问题。最后进行实时监控,当发现缓存的命中率急剧降低,需排查访问对象和数据,和运维设置黑名单限制服务(摇人帮忙)。

缓存击穿

缓存击穿的现象是应用服务器压力瞬时增大,缓存里也没有出现大量的键过期,且依旧处于正常的运行状态。通常情况下是由于缓存中的某个键过期,同时有大量并发请求过来,在这些请求发现缓存过期时会向后端数据库发送海量并发请求造成的。

对于这种问题可以考虑进行预先设置热门数据,即在高峰访问前,将热门数据提前存入到缓存,并增加热门数据键的失效时长。其次可以使用锁,在缓存失效时先判断拿出来的值是否为空,空值就应该立刻设置排他锁,在成功设置排他锁后查询数据库,同步缓存,最后删除排他锁。但是用到锁,那么必定效率降低。

缓存雪崩

缓存雪崩的现象是数据库压力增大以至服务器崩溃。通常情况下是由于在短暂时间内有大量键的集中过期的情况造成的。

解决方案是构建多级缓存的架构,nginx 缓存、redis 缓存搭配其他缓存(ehcache 等)。也可以使用锁或队列来保证不会有大量线程对数据库同时读写,从而避免失效时大量的并发请求落到底层存储系统上,但这种方式并不适用高并发的情况。其次可以设置过期标志更新缓存,如果键过期,那么触发通知另外的线程在后台去更新实际键的缓存。最佳做法是将缓存失效时间分散,在原有的失效时间基础上增加一个随机值,这样每一个缓存的过期时间的重复率就会降低。

缓存雪崩与缓存击穿的区别在于前者是针对很多键的缓存集体失效,后者则是某一个键突然被海量请求。

分布式锁

单机部署的系统被演化成分布式集群架构后,由于分布式系统的多线程与多进程分布在不同机器,原单机部署情况下的并发控制锁策略失效,单纯的 Java API 并不能提供分布式锁的能力。这里需要分布式锁进行跨 JVM 的互斥机制控制共享资源的访问。

数据库、redis 和 Zookeeper 都能实现分布式锁。可靠性 zookeeper 最高,性能上 redis 最佳。

# 设置锁 => setnx
xxx.xx.xx.xxx:6389> setnx users 10
-> Redirected to slot [14124] located at xxx.xx.xx.xxx:6381
(integer) 1
xxx.xx.xx.xxx:6381> setnx users 11
(integer) 0
# 释放锁 => setnx
xxx.xx.xx.xxx:6381> del users
(integer) 1
# 指定时间锁
xxx.xx.xx.xxx:6381> setnx users 10
(integer) 1
xxx.xx.xx.xxx:6381> expire users 10
(integer) 1
xxx.xx.xx.xxx:6381> ttl users
(integer) 5
# 上锁时设置过期时间 => 避免上锁后异常无法设置过期时间
xxx.xx.xx.xxx:6381> set users nx ex 10
OK
  • UUID 防误删以及删除原子性整合

原子性场景是当👩🏻‍💻在上锁后进行操作,于释放锁阶段准备删除时,锁到了过期时间被自动释放,此时🧑🏻‍💻获取了锁并进行具体操作,那么👩🏻‍💻进行的删除操作会将🧑🏻‍💻的锁删除。

=> 为确保分布式锁可用,至少要确保锁的实现同时满足以下条件

  • 互斥性。在任意时刻,只有一个客户端能持有锁。
  • 去死锁。即使有客户端在持有锁的期间崩溃而未主动解锁,也要保证后续其他客户端能加锁。
  • 加锁和解锁必是同一客户端,客户端不能把其他客户端加的锁给解除。
  • 加锁和解锁必须具有原子性。
@ResetController
@RequestMapping("/redisTest")
public class RedisTestController{
    @Autowired
    private RedisTemplate redisTemplate;
    @GetMapping("testLock")
    public void testLock(){
        String uuid = UUID.randomUUID().toString();
        // 上锁
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS);
        // 获取锁、查询值
        if(lock){
            Object value = redisTemplate.opsForValue().get("num");
            // 判断 num
            if(StringUtils.isEmpty(value)){
                return;
            }
            // 有值就转成 int
            int num = Integer.parseInt(value+"");
            // 把 num 加 1
            redisTemplate.opsForValue().set("num", ++num);
            // 释放锁 => 比较 UUID
            String lockTestUUID = (String)redisTemplate.opsForValue().get("lock");
            if(lockTestUUID.equals(uuid)){
                redisTemplate.delete("lock");
            }
        }else{
            // 获取锁失败
            try {
                // 每隔 0.1 秒再获取
                Thread.sleep(100);
                testLock();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    @GetMapping("testLockLua")
    public void testLockLua() {
        // 声明 uuid 做为一个 value 放入 key 所对应的值中
        String uuid = UUID.randomUUID().toString();
        // 定义一个锁 => lua 脚本使用同一把锁来实现删除
        String objId = "10"; // 访问 objId 为 10 号的物品 100008348542
        String locKey = "lock:" + objId; // 锁住的是每个商品的数据
        // 获取锁
        Boolean lock = redisTemplate.opsForValue().setIfAbsent(locKey, uuid, 3, TimeUnit.SECONDS);
        // 第一种 => lock 与过期时间中间不写任何的代码
        // redisTemplate.expire("lock",10, TimeUnit.SECONDS); // 设置过期时间
        if (lock) { // true
            // 执行的业务逻辑开始
            // 获取缓存中的 num 数据
            Object value = redisTemplate.opsForValue().get("num");
            // 是空直接返回
            if (StringUtils.isEmpty(value)) {
                return;
            }
            // 不是空 => 出现了异常那么 delete 失败 -> 锁永存
            int num = Integer.parseInt(value + "");
            // 使 num 每次 +1 放入缓存
            redisTemplate.opsForValue().set("num", String.valueOf(++num));
            /* 使用 lua 脚本来锁 */
            // 定义lua 脚本
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            // 使用 redis 执行 lua 执行
            DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
            redisScript.setScriptText(script);
            // 设置一下返回值类型为 Long
            // 删除判断的时返回的 0 => 给其封装为数据类型 -> 不封装默认返回 String
            // 返回字符串与 0 会有错误发生
            redisScript.setResultType(Long.class);
            // script 脚本、判断的key、key 所对应的值
            redisTemplate.execute(redisScript, Arrays.asList(locKey), uuid);
        } else { // 其他线程等待
            try {
                Thread.sleep(1000); // 睡眠
                testLockLua(); // 睡醒调用方法
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

新特性

ACL 访问控制列表

Access Control List 在可执行命令和可访问的键方面对某些连接进行限制。

# 展示当前用户与操作权限
xxx.xx.xx.xxx:6381> acl list
# 具体的操作命令
xxx.xx.xx.xxx:6381> acl cat
# 命令查看当前用户
xxx.xx.xx.xxx:6381> acl whoami
类型 参数 说明
启动和禁用用户 on|off 激活|禁用某用户账号
权限的添加删除 +|-<command> +@|-@<category> allcommands|nocommands 将指令添加到用户可执行指令列表;从用户可执行指令列表移除指令 添加该类别中用户要调用的所有指令;有效类别为 @admin、@set、@sortedset…,通过调用 ACL CAT 查看完整列表。特殊的 @all 表示所有命令,包括当前存在于服务器中的命令,以及将来将通过模块加载的命令|从用户可调用指令中移除类别 +@all的别名|-@all的别名
可操作键的添加或删除 ~<pattern> 添加可作为用户可操作的键的模式 => ~* 表示允许所有的键
# 添加用户
xxx.xx.xx.xxx:6381> acl setuser test
xxx.xx.xx.xxx:6381> acl list
2) "user test off resetchannels -@all"
# 设置有用户名、密码、ACL权限、并启用的用户
xxx.xx.xx.xxx:6381> acl setuser ontest on >password ~cached:* +get
# 切换用户|验证权限
xxx.xx.xx.xxx:6381> auth ontest password
OK
xxx.xx.xx.xxx:6381> acl whoami
(error) NOPERM this user has no permissions to run the 'acl|whoami' command

IO 多线程

Redis6 支持的多线程其实指处理网络数据的读写和协议解析为多线程,而非执行命令多线程。Redis6 对于执行命令依然是单线程。简而言之依旧是单线程与多路 IO 复用。

支持 Cluster

老版 Redis 想要搭集群需单独安装 ruby 环境,redis5 将 redis-trib.rb 的功能集成到 redis-cli。此外官方 redis-benchmark 工具也支持 cluster 模式,通过多线程的方式对多个分片进行压测。

其他更新

  • 新的 Redis 通信协议 RESP3 用于优化服务端与客户端之间通信
  • Client side caching 客户端缓存(基于 RESP3 协议实现)进一步提升缓存的性能,将客户端经常访问的数据 cache 到客户端。减少 TCP 网络交互
  • Proxy 集群代理模式让 Cluster 拥有像单实例一样的接入方式,降低使用 cluster 的门槛。不过需要注意的是代理不改变 Cluster 的功能限制,不支持的命令还是不会支持,比如跨 slot 的多键操作
  • Modules API 使 Redis 可以变成一个框架,利用 Modules 来构建不同系统

Bugs 解决

  • NOAUTH Authentication required.
redis.clients.jedis.exceptions.JedisDataException: NOAUTH Authentication required.

SSM 框架配置 redis 的 Jedis 相关 xml 配置后,进行写入测试,出现数据异常。原因在于未对 redis 进行授权。

<!-- application.xml -->
<bean id="jedisPool" class="redis.clients.jedis.JedisPool">
    <constructor-arg name="user" value="xxx"></constructor-arg>
    <constructor-arg name="password" value="xxx"></constructor-arg>
    <property name="..." value="..." />
</bean>
  • [ERR] Node REMOTEIP:PORT is not empty. Either the node already knows other nodes (check with CLUSTER NODES) or contains some key in database 0.

异常的节点可能与其他节点组成过集群,或者在数据库中包含一些数据。

首先应停止服务,若不停止服务直接删除文件可能无效;在删除生成的备份 aof、rdb、nodes.conf 文件后重启服务;在必要的情况下也可以执行 flushdb

  • [ERR] Not all 16384 slots are covered by nodes.

这个往往是由于主 node 移除,但并没有移除 node 上面的 slot,从而导致 slot 总数没有达到 16384,导致 slots 分布不正确。在删除节点的时候一定要注意删除的是否是 Master 主节点。

# redis-cli --cluster fix host:port => 修复集群
[centos myredis]# redis-cli --cluster fix 127.0.0.1:6379 
# redis-cli --cluster check host:port => 检查
[centos myredis]# redis-cli --cluster check 127.0.0.1:6379
# redis-cli --cluster reshar host:port => 重新分配 slot
[centos myredis]# redis-cli --cluster reshard 127.0.0.1:6379
127.0.0.1:6379> cluster nodes # 查看节点中集群信息
  • com.fasterxml.jackson.databind.ObjectMapper? ⌥⏎

springboot 整合 redis 出现 ObjectMapper 找不到相关包的问题。SOF

Data Binding API is used to convert JSON to and from POJO (Plain Old Java Object) using property accessor or using annotations. It is of two type. Simple Data Binding - Converts JSON to and from Java Maps, Lists, Strings, Numbers, Booleans and null objects.

// 问题代码
ObjectMapper om = new ObjectMapper();
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.13.3</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-core</artifactId>
    <version>2.13.3</version>
</dependency>
  • Cannot Resolve Symbol RestController

在 pom.xml 中加入 spring-boot-starter-web 的依赖。常见于创建 springboot 项目时未选择 web 相关 starter,也会导致启动时不载入 web。

结束

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议,转载请注明出处!

]]>
<![CDATA[图解 HTTP 摘要]]>https://zairesinatra.github.io//httptextbook/6264efa3aa2820757fbb1478Mon, 09 Aug 2021 13:09:00 GMT

Web Basis

Web and Networking Fundamentals

图解 HTTP 摘要

Web 是建立在 HTTP HyperText Transfer Protocol 上通信的。

通常所使用的网络是在 TCP/IP 协议族的基础上运作的。HTTP 属于它的子集。

TCP/IP 协议族按层次可分为:应用层、传输层、网络层和链路层。

OSI 模型是国际化组织 ISO 提出的世界范围计算机互联框架。

OSI 七层模型各自实现功能与协议,并能与相邻层的接口进行通信。

应用层 => 表示层 => 会话层 => 传输层 => 网络层 => 数据链路层 => 物理层

HTTP/1.1 规范允许一台 HTTP 服务器搭建多个 Web 站点。

IP & TCP 和 URI & URL

负责传输的 IP 协议:

  • IP Internet Protocol 网际协议位于网络层
  • 此 IP 作为一种协议的名称,非 IP 地址的 IP
  • IP 协议的作用是将各种数据包进行传送
  • 确保传递的条件:IP 地址和 MAC 地址 Media Access Control Address
    • IP 地址指明节点被分配到的地址
    • MAC 地址是网卡所属的固定地址

确保可靠性的 TCP 协议:

字节流服务 Byte Stream Service 是指为了方便传输,将大块数据分割成以报文段 segment 为单位的数据包进行管理。

  • TCP 位于传输层,提供可靠的字节流服务
  • TCP 协议能够确认数据最终是否送达到指定方(三次握手)
  • 握手过程使用 TCP 的标志 flag => SYN synchronize & ACK acknowledgement

发送端先发送带 SYN 标志的数据包;接收端收到后回传带有 SYN/ACK 标志的数据包以示传达确认信息。最后发送端再回传一个带 ACK 标志的数据包,代表握手的结束。若握手过程中的某个阶段被莫名中断,TCP 协议会再次以相同的顺序发送相同的数据包。

URI 用字符串标识某一互联网资源,而 URL 表示资源的地点:

  • URL 是访问页面时需要输入的地址
  • URI 是由某个协议方案所表示的统一资源标识符
  • URL 是 URI 的子集

协议方案是指访问资源时所使用的协议类型名称。常见有 http、ftp、telnet 等。

HTTP response status codes

状态码的职责是描述客户端向服务器发送请求的结果。借助状态码,开发者可以知道服务器端是正常处理了请求,还是出现了错误。

2XX Success

200 OK: The request has succeeded. A 200 response is cacheable by default.

204 No Content: A request has succeeded, but that the client doesn't need to navigate away from its current page.

206 Partial Content: The request has succeeded and the body contains the requested ranges of data, as described in the Range header of the request.

3XX Redirection

301 Moved Permanently: The requested resource has been definitively moved to the URL given by the Location headers.

302 Found: The requested resource was temporarily moved to the URL specified by the Location header.

The Common Gateway Interface (CGI) provides the middleware between WWW servers and external databases and information sources.

303 See Other: This response code is often sent back as a result of PUT or POST. The method used to display this redirected page is always GET.

304 Not Modified:The resource on the server side has not changed, and the unexpired cached content on the client side can be used directly. Although it is classified in the 3XX category, it has nothing to do with redirection.

307 Temporary Redirect: The target resource resides temporarily under a different URI and the user agent MUST NOT change the request method if it performs an automatic redirection to that URI.

4XX Client Error

400 Bad Request: There is a syntax error in the request message.

401 Unauthorized: The client request has not been completed because it lacks valid authentication credentials for the requested resource.

403 Forbidden: The server understands the request but refuses to authorize it.

404 Not Found: The server cannot find the requested resource.

5XX Server Error

500 Internal Server Error: The server encountered an unexpected condition that prevented it from fulfilling the request.

503 Service Unavailable: the server is not ready to handle the request.

503 状态码表明服务器暂时处于超负载状态或正在进行停机维护,现在无法处理请求。条件允许时,可以将确定的恢复时间写入响应的首部字段 Retry-After。

504 Gateway Timeout: The server, while acting as a gateway or proxy, did not get a response in time from the upstream server that it needed in order to complete the request.

HTTP header & Message

请求/响应报文由以下内容组成:请求行、HTTP 头字段、空行、可选的 HTTP 报文主体数据

message 是 HTTP 通信中的基本单位(即站点一次性要发送的数据块)。

实体作为请求或响应的有效载荷数据(补充项)被传输,由实体首部和实体主体组成。内容编码指明应用在实体内容上的编码格式,并保持实体信息原样压缩。内容编码后的实体由客户端接收并负责解码。常用的内容编码有 gzip、compress 等。

在请求的编码实体资源尚未传输完成前,浏览器无法显示请求的页面。当传输大容量的数据时,可以通过将数据分割成多块的方式让浏览器逐步的显示页面。

这种把实体主体分块的功能称为分块传输编码 Chunked Transfer Coding。

使用分块传输编码的实体主体会由接收的客户端负责解码,恢复到编码前的实体主体。

HTTP 协议的请求和响应报文中必定包含 HTTP 首部。

首部内容为客户端和服务器分别处理请求和响应提供所需要的信息。客户端用户无须亲自查看。

HTTP 首部字段由字段名和字段值构成,中间用冒号 : 分隔。

// 首部字段名: 字段值
Content-Type: text/html // 报文主体的对象类型
Keep-Alive: timeout=15, max=100...

HTTP 首部字段根据实际的用途可分为四种类型:

  • 通用首部字段 General Header Fields => 请求和响应报文都会使用到
  • 请求首部字段 Request Header Fields => 请求报文使用的首部
  • 响应首部字段 Response Header Fields => 响应报文使用的首部
  • 实体首部字段 Entity Header Fields => 请求和响应报文的实体使用的首部

首部字段不限于 RFC2616 中定义的 47 种首部字段,还有 Cookie、Set-Cookie 和 Content-Disposition 等在其他 RFC 中定义的首部字段。这些非正式的首部字段统一归纳在 RFC4229 HTTP Header Field Registrations 中。

无状态协议的优点:减少服务器 CPU 以及内存资源的消耗。

Cookie 技术是通过在请求和响应报文中写入 Cookie 信息来控制客户端的状态。

根据响应报文内的 Set-Cookie 字段通知客户端保存 Cookie。后续客户端再往该服务器发送请求时,会自动的在请求报文中加入 Cookie 值。

服务器端获取客户端所发送的 Cookie 后,会去检查究竟是从哪一个客户端发来的连接请求,然后对比服务器上的记录得到之前的状态信息。

强缓存与协商缓存

缓存能减少不必要的数据传输,在减轻服务器负担的同时,可以提升网站的响应速度。Cache 访问的优先级:memory cache -> disk cache -> HTTP Request。

强缓存:浏览器不向服务器发送请求,直接从本地读取缓存的文件并返回成功状态码 200 ok。

强缓存的主要依托:

  • HTTP/1.0 中的 Expires 标头 => 明确的时间
  • HTTP/1.1 中的 Cache-Control: max-age => 指定经过的时间

If a response includes both an Expires header and a max-age directive, the max-age directive overrides the Expires header, even if the Expires header is more restrictive. RFC2616

协商缓存:浏览器向服务端发送请求,服务端根据请求的参数来判断是否命中协商缓存。若命中协商缓存,那么通知浏览器从缓存中读取资源并返回状态码 304 Not Modified,未命中则返回最新资源并附带成功状态码 200 ok。

协商缓存的主要依托:

  • response header 的 etag 与 last-modified
  • request header 的 if-none-matched 与 if-modified-since

HTTPS for web security

HTTP 缺陷:通信明文且不加密;通信方身份不验证;报文无法证明完整性。

通信的加密:

  • HTTP 协议可以通过 SSL Secure Socket Layer 安全套接层加密通信内容
  • 与 SSL 组合使用的 HTTP 称为 HTTPS HTTP Secure 超文本传输安全协议

内容的加密:最好要求客户端与服务器同时具备加密和解密的机制。

请求和响应不对通信方进行确认:各种隐患以及隐藏在海量请求下的 DDoS。

SSL 使用证书确定通信方:证书由可信任的第三方颁发,证明双边的实际存在。

无法证明通信报文的完整性:当请求或响应送出后,即使内容遭到篡改,也没有办法获悉。

请求或响应传输途中所遭到的攻击拦截与内容篡改称为中间人攻击 MITM Man-in-the-Middle attack。可使用 MD5 或 SHA-1 散列值来校验文件的数字签名。

SSL 独立于 HTTP,也能和其他应用层的协议(如 SMTP、Telnet)配合使用。

常规情况是 HTTP 和 TCP 直接通信,但使用了 SSL 后,HTTP 会先和 SSL 通信,再由 SSL 和 TCP 通信。

HTTPS 采用共享密钥加密和公开密钥加密两者的混合加密机制。

Identifying HTTP users

HTTP Basic Authentication

basic 认证即基本认证,是 HTTP/1.0 时定义的服务器与客户端之间的认证。

服务器响应返回 401 Authorization Required,且带回 WWW-Authenticate 首部字段。该字段内包含认证方式 BASIC 以及 Request-URI 安全域字符串 realm。

客户端将 ID 与密码发送给服务器,两者间以冒号连接,再经 Base64 编码处理。

接收到包含首部字段 Authorization 请求的服务器,会对认证信息进行验证。验证通过后会返回一条包含 Request-URI 资源的响应。

basic authentication 虽采用 Base64 编码,但这并不是加密处理(不安全)。

Digest Access Authentication

HTTP/1.1 出现的 Digest 认证采取质询/响应 challenge/response 的方式。

SSL Client Certificate Authentication

仅根据 ID 和密码的正确无法排除可能是他人冒充的情况。

SSL 客户端认证:借助 HTTPS 客户端证书来完成认证。

凭借证书,服务器可以确定来访的客户端。这种认证方式需要事先分发证书,且客户端必须安装此证书。

多数情况下,这种认证方式还会和表单认证组合成一种双因素的认证来使用。

Form-based Authentication

基于表单的认证并不是在 HTTP 协议中所定义的。

客户端向服务器应用发送登录信息,根据认证的结果来决定认证是否成功。

客户端将登录信息放入报文的实体部分并发送给服务器。服务器会发放识别用户的 sessionid。用户的认证状态与 sessionid 绑定后会被记录在服务器。sessionid 通常会放在响应首部字段的 Set-Cookie 中。

为减少跨站脚本攻击 XSS,建议事先在 Cookie 内加上 httponly 属性。

客户端将 sessionid 作为 Cookie 保存在本地。后续的请求会自动携带 Cookie。

服务器会通过客户端请求中所携带的 sessionid 来验证用户的状态。

HTTP-based Protocols

SPDY

SPDY 发音如英语 speedy,是一种由 Google 开发的开放网络传输协议,基于传输控制协议 TCP 的应用层协议。SPDY 也是 HTTP/2 的前身。

异步 JavaScript 与 XML 和以前的同步通信相比,只需要更新一部分的内容,这可以有效减少响应中所需要传输的数据量。

SPDY 以会话层形式加入,控制数据的流动,但还是采用 HTTP 所建立的连接。

使用 SPDY 以获得的功能:多路复用流、赋予请求优先顺序、压缩 HTTP 首部、推播通知和服务器提示。

全双工通信 WebSocket

服务器与客户端所建立的 WebSocket 通信连接,任意方都可直接发送报文。

WebSocket 的首部信息较 HTTP 更小;WebSocket 通信需要在 HTTP 连接建立后完成一次握手。

实现 WebSocket 通信需要用到 HTTP 的 Upgrade 首部字段:告知服务器通信协议所发生的改变。

WebSocket 通信不使用 HTTP 数据帧,而采用 WebSocket 独立的数据帧。

结束

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议,转载请注明出处!

]]>
<![CDATA[NodeJS]]>https://zairesinatra.github.io//node/612a36d2f60ced083419000dThu, 08 Jul 2021 15:02:00 GMT

快速上手

浏览器内核

NodeJS

常说的浏览器内核指的是浏览器排版引擎 layout engine,也可称为浏览器引擎、页面渲染引擎或样板引擎。

主流浏览器内核组成:

  • IE/Edge browser:Trident 转向 Blink
  • Chrome browser:Webkit 转向 Blink -> 统称为 Chromium
  • Firefox browser:Gecko
  • Safari browser:Webkit
  • Opera browser:Presto 变更为 Webkit,现在转向 Blink

浏览器内核负责对网页语法的解释以及渲染网页,通常由渲染引擎和 JS 引擎两部分组成。

浏览器渲染过程

在浏览网页过程中,所有资源并非一捆下载,而是解析时到具体的标签时,再去相应的定位处下载资源。

  • 进入页面 HTML 被首先下载,HTML Parser 开始解析标签并生成 DOM Tree
  • DOM Tree 生成时会有 JS 引擎帮助解析 JavaScript 代码对 DOM 进行操作
  • 因 HTML 的解析过程是自上而下的,所以在遇见 <link> 标签表示的 CSS 样式时,会根据 CSS Paser 生成对应的 CSS Rules 并与 DOM Tree 结合产生 Render Tree
  • Render Tree 在生成时会根据 Layout Engine 进行针对浏览器状态的适配调整,并在完成后进行 Painting 与 Display
  • HTML 解析过程中遇见 JavaScript 会停止解析,不同于 CSS 异步加载执行,因为 JavaScript 代码可以操作 DOM
NodeJS

JavaScript Engine

高级的编程语言最终都是需要转成机器指令来执行的。JavaScript 无论是交给浏览器还是 Node.JS,最后都是需要只认识机器语言指令集的 CPU 执行。所以需要 JS 引擎帮助将 JavaScript 代码翻译成 CPU 指令进行执行。

=> 常见 JavaScript Engine
SpiderMonkey => The first JavaScript engine & Developed by Brendan Eich
Chakra => 微软开发并用于 IE 浏览器
JavaScriptCore => Apple 开发并用于 WebKit
v8 => Google 开发用于 Chromium

V8 引擎原理

C++ 编写的 V8 engine 由 Google 开源,是高性能 JS 和 WebAssembly 引擎,用于 Chrome 和 Node 等。V8 可以独立运行,也可以嵌入到任何 C++ 应用程序中。

V8 engine implements ECMAScript and WebAssembly, and runs on Windows 7 or later, macOS 10.12+, and Linux systems that use x64, IA-32, ARM, or MIPS processors.

  • ECMAScript is a Standard for scripting languages such as JavaScript, JScript, etc. It is a trademark scripting language specification. JavaScript is a language based on ECMAScript. A standard for scripting languages like JavaScript, JScript is ECMAScript.
  • WebAssembly (abbreviated Wasm) is a binary instruction format for a stack-based virtual machine. Wasm is designed as a portable compilation target for programming languages, enabling deployment on the web for client and server applications.

JS 经过词法分析生成由 type 与 value 组成的对象并存储在 tokens 数组。随后的语法分析生成 AST 抽象语法树。

类似 Babel 将 TS 转为 JS,也是先生成 Abstract Syntax Tree 并作出相应修改,产生 new Abstract Syntax Tree,再 generate code 为 JS。

同理 Vue 中 Template 也是先转换成 Abstract Syntax Tree 再 CreateVNode 虚拟节点,最后产生 JS。

const name = 'zs'
/* 词法分析 */
[
  { type: 'Keyword', value: 'const' },
  { type: 'Identifier', value: 'name' },
  { type: 'Punctuator', value: '=' },
  { type: 'String', value: "'zs'" }
]

/* 语法分析 */
{
  "type": "Program",
  "start": 0,
  "end": 17,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 17,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 6,
          "end": 17,
          "id": {
            "type": "Identifier",
            "start": 6,
            "end": 10,
            "name": "name"
          },
          "init": {
            "type": "Literal",
            "start": 13,
            "end": 17,
            "value": "zs",
            "raw": "'zs'"
          }
        }
      ],
      "kind": "const"
    }
  ],
  "sourceType": "module"
}
NodeJS

官方 V8 引擎原理中是通过 Blink 将源码交给 V8 engine,以 Stream 获取到源码并且进行编码转换;Scanner 进行词法分析生成 tokens,再经过 Parser 和 PreParser 转换成 AST。不同于前者的直接解析转换,后者是预解析,因为并不是所有的 JS 代码在一开始时就会被执行,所以通过延迟解析将不必要的函数进行预解析(函数的全量解析是在函数被调用时才进行 => 函数 outer 内部定义函数 inner,那 inner 函数就会进行预解析)。生成的抽象语法树会被 Ignition 转成字节码后执行。

IO 密集

非阻塞 IO 模型 => IO 即计算机输入输出,常见有外接硬件、磁盘读写、网络传输和数据库操作。阻塞 IO 即进行 IO 操作时,进程处于休眠状态,等待 IO 操作完成再通知主进程进行后续处理(触发事件函数通知主进程)。

事件驱动 => 区别于 Nginx 多进程单线程,NodeJS 通过事件驱动的方式处理请求时无需为每一个请求创建额外的线程。每一个 IO 操作都会被添加到事件队列中,线程循环地处理队列上的工作任务,当执行过程中遇到阻塞,线程不会停下来等待结果,而是留下一个处理结果的回调函数,转而继续执行队列中的下一个任务。这个传递到队列中的回调函数在阻塞任务运行结束后才被线程调用。

  • IO 密集与 CPU 密集

CPU 密集 => 程序大部分时间用于处理逻辑运算、文件压缩解压与数据加密解密

IO 密集 => 程序大部分时间用来做数据存储以及网络读取的操作

  • WEB 开发的 IO 密集

当浏览器中的请求到达服务器时,除了使用 cpu 进行计算 uri 路径的文件位置,剩下的都是文件读取以及数据库操作,由此可见 http 请求大部分还是 IO 操作。此外在页面渲染时,除开使用 cpu 计算,其他涉及读取模板文件或根据数据生成 Html 都算做 IO 操作。所以说 WEB 开发是典型的 IO 密集的场景。

  • 进程与线程

进程 Process 通俗来说就是正在内存中运行的程序,多进程是通过 cpu 调度算法在纳秒单位切换执行多个进程。线程是进程内一个相对独立的可调度的执行单元,也就是进程中单一顺序的控制流。多任务就是说在一个进程可以并发多个线程,而每条线程并行执行不同的任务。一个进程开启一项任务,就是打开了一个线程,类似于 Downie4 开始了一个下载任务。

  • REPL Read-Eval-Print Loop => 交互式编程环境。
$ node
Welcome to Node.js v14.17.1.
Type ".help" for more information.
$ process
process { version: 'v14.17.1', versions: { node: '14.17.1', v8: '8.4.371.23-...
node index.js zszszs age=22 // 给 node 传参使用 process 内置对象的 argv(argument vector) 接收
console.trace(); // 打印函数调用栈

Global object & Module

  • 全局对象 => 在程序的任何位置可以直接访问的对象

在 JS 源代码通过 Parse 转换为 AST 的阶段,会创建全局对象 GlobalObject,并放入 window 属性(即 this 指向当前 GlobalObject)、setTimeout...。存在部分特殊的全局变量,这些全局变量实际上是模块中的变量,只不过每个模块都有,所以看起来好像是全局变量,但在命令行交互中不可直接使用。

// 常见的全局变量
process // 提供Node进程相关信息
console // 控制台
// 定时器全局函数
setTimeout(() => {},time)
setInterval(() => {},time)
setImmediate(() => {})
process.nextTick(() => {})
// 全局对象
global // global. 按两下 Tab
// 特殊的全局变量
// 需要进入文件所在文件夹再 node index.js
__dirname // 打印当前文件夹所在目录绝对路径
__filename // 打印当前文件所在目录绝对路径
exports、module、require() // 模块化相关

V8 在创建 GO 后,为执行代码还需要 ECStack Execution Context Stack 执行上下文栈作为容器去接收 GEC Global Execution Context 全局执行上下文。

VO Variable Object 变量对象在编译时,其内每一项只是对应 GO 的初始未赋值属性,只有在执行期间才会对相应属性更改。

/* --- 伪代码 --- */
/*
GO =>
var globalObject = {
  String:"Class", Date:"Class",setTimeout:"Func",window:globalObject,name:undefined,age:undefined
}
*/
// 解释变量提升为什么出现 undefined
var name = "zs"
console.log(age) // 编译完成但未执行 => GO 中还是 undefined
var age = 22
  • CJS 与 ESM 模块的导入与导出

Node CJS 不同于 ES6 ESM。后者使用 export 与 export default 导出,在需要模块的文件中以 import from "模块名" 导入。

export 导出需使用 {} 配合导入;export default 导出的模块可直接导入,无需明确所要加载模块的变量名(默认名)。模块中 export、import 不做限制,而 export default 最多只能有一个。

// ES6 —— export
// a.js
export const str = "zairesinatra";
export function zs (sth) { 
  return sth;
}
// 对应的导入方式:
// b.js
import { str, zs } from 'a'; // 也可以分开写两次,导入的时候带花括号

// ES6 —— export default
// a.js
const str = "zairesinatra";
export default str;
// 对应的导入方式:
//b.js
import str from 'a'; //导入的时候没有花括号

// ES6 —— 自由命名
// a.js
let name = "xzy";
export default name // name不能加大括号
// 原本直接export name外部是无法识别的,加上default就可以了.但是一个文件内最多只能有一个export default。
// 其实此处相当于为name变量值"xzy"起了一个系统默认的变量名default,自然default只能有一个值,所以一个文件内不能有多个export default。
// b.js
// 本质上,a.js文件的export default输出一个叫做default的变量,然后系统允许你为它取任意名字。所以可以为import的模块起任何变量名,且不需要用大括号包含
import zs from "./a.js"
import zy from "./a.js" 
console.log(zs,zy)   // xzy,xzy

CommonJS 使用 require 引入后的返回只有 module.exports 对象。exports 对象实际上只是对 module.exports 的引用。module.exports 初始值为一个空对象 {}

// 区分 exports 与 module.exports
// 如果覆盖 exports 的值,那么将丢失对 module.exports 的引用,而 module.exports 就是作为公共接口公开的内容
var module = new Module(...);
var exports = module.exports;
// CommonJS —— 导出单个模块
// formatTime
function formatTime (){
    // 需要的格式 yyyy-MM-dd hh:mm:ss
    var date = new Date(); // 或者传入一个时间戳
    Y = date.getFullYear() + '-';
    M = (date.getMonth()+1 < 10 ? '0'+(date.getMonth()+1) : date.getMonth()+1) + '-';
    D = date.getDate() + ' ';
    h = date.getHours() + ':';
    m = date.getMinutes() + ':';
    s = date.getSeconds(); 
    console.log(Y+M+D+h+m+s); // 当前时间格式化输出
}
module.exports = formatTime
// requireFT
var requireFT = require('./formatTime') // 当然 .js 可以省略
requireFT() // 2019-08-29 20:33:48

// CommonJS —— 导出多个模块
// func.js
var func1 = () =>{console.log('I am func1')}
var func2 = function(){console.log('I am func2')}
module.exports.func1 = func1;
module.exports.func2 = func2;
// 可简写
module.exports = { // 键值同名可以只写一个
    func1: func1, // func1,
    func2: func2 // func2
}

// requireFunc.js
var requireObj = require('./formatTime')
requireObj.func1(); // I am func1
requireObj.func2(); // I am func2
// mod1.js
let name = "ok"; // 字符串是值引用,已经指定了地址
setTimeout(() => { name = "okk" }, 1000)
module.exports = { name: name }
// exec1.js
const mod1 = require('./mod1')
setTimeout(() => { console.log(mod1.name); }, 2000) // ok

// mod2.js
let info = { name: "ok"}; // 引用对象在堆内存开辟空间
setTimeout(() => { info.name = "okk" }, 1000) // 改变了指针
module.exports = { info } // info 赋值的是内存地址
// exec2.js
const mod2 = require('./mod2')
setTimeout(() => { console.log(mod2.info.name); }, 2000) // okk

require 是帮助引入模块导出对象的同步函数。require 查找模块首先会判断是否为核心模块,是则直接返回并停止查找;不是则分类讨论。

./..// 开头的文件有后缀名时,先按指定后缀查找,没有则按 js 文件、json 文件、node 文件的顺序查找。若仍无相应文件,则按目录名查找。

目录名查找 => 从最近的 node_modules 文件夹查到根目录的 node_modules 文件夹。

  • 模块的加载过程

模块首次引入时会直接运行一次;模块被多次引入也只会加载一次。因每个模块对象都有一个属性 loaded 表示加载完成的状态。

/* module 对象的属性 */
module.id 模块的识别符,通常是带有绝对路径的模块文件名。
module.filename 模块的文件名,带有绝对路径。
module.loaded 返回一个布尔值,表示模块是否已经完成加载。
module.parent 返回一个对象,表示调用该模块的模块(程序入口文件的module.parent为null)
module.children 返回一个数组,表示该模块要用到的其他模块。
module.exports 表示模块对外输出的值。

算法中图结构在遍历时有深度优先搜索和广度优先搜索,模块在引入时采用深度优先搜索。下图加载顺序为 main、a、c、d、e、b。

NodeJS
  • 其他模块化 => AMD 和 CMD

AMD 是 Asynchronous Module Definition 移步模块化的缩写,采用异步加载模块。常用的库是 require.js 和 curl.js。

<script src="./lib/require.js" data-main="./index.js"></script>
// index.js
(function(){
  require.config({
    baseUrl: '',
    path: {
      "bar": "./modules/bar", // 对应模块的映射关系
      "foo": "./modules/foo"
    }
  })
  require(['foo'], function(foo){})
})()
// bar.js
define(function() {
  const name = "zszs";
  const age = 21;
  const sayHello = function(){
    console.log("hi" + name);
  }
  return {
    name: name,
    age: age,
    sayHello: sayHello
  }
})
// foo.js
define(['bar'], function(bar){
  console.log(bar.name);
  console.log(bar.age);
  bar.sayHello("zy");
})

CMD 是 Common Module Definition 通用模块定义的缩写,也采用异步加载模块。实现方案是 SeaJS。

<script src="./lib.sea.js"></script>
<script>
  seajs.use('./index.js')
</script>
// index.js
define(function(require, exports, module){
  const foo = require('./modules/foo');
  console.log(foo.name);
  console.log(foo.age)
  console.log(foo.sayHello('zy'))
})
// foo.js
define(function(require, exports, module){
  const name = "zs";
  const age = 21;
  const sayHello = function(name){
    console.log("hi" + name);
  }
  module.exports = {
    name,
    age,
    sayHello
  }
})

内置模块

File system

任何为服务端服务的语言或框架通常会有各自的文件系统。fs 模块用于文件系统交互。

  • fs.existsSync(path) => 检查传参 path 是否存在;异步 fs.exists 已废弃

Node 大部分异步 API 会有回调函数 callback 作为参数,而大部分同步 API 不会。

// 创建文件夹
const fs = require('fs')
const dirname = './testDirectory'
if(!fs.existsSync(dirname)){
  fs.mkdir(dirname, err => { console.log(err); });
}
  • fs.readdir(path[, options], callback) => 读取目录的内容;回调函数的参数 files 是目录中文件名的数组,不递归向下
// 读取文件夹所有文件
const fs = require('fs')
fs.readdir("./testDirectory", (err, files) => {
  console.log(files)
})
  • fs.rename(oldPath, newPath, callback) => 对文件异步重命名;newPath 已经存在的情况下,文件会被覆盖,目录则会抛出错误
// 重命名
const fs = require('fs');
fs.rename("./files/dirtest", "./files/dirtestandrename", err => {
  console.log(err)
})
  • fs.readFile(path[, options], callback) => 异步读取文件的全部内容

fs.readFile() 是对 fs.read() 的封装,后者使用流程应先用 fs.stat() or fs.fstat() 获取对象文件信息,再通过 fs.open() 创建文件描述符 fd,最后才能以 fs.read() 读取内容。

// readFile() 读取文件
const fs = require("fs");
fs.readFile("./files/1.txt", {encoding: 'utf-8'}, function (err, dataStr) {
  if (err) { return console.log("读取文件失败!" + err.message); } // 读取成功则 err 的结果为 null, 失败则 dataStr 的结果为 undefine
  return console.log("读取文件成功!" + dataStr);
});
  • fs.writeFile(file, data[, options], callback) => 当 file 是文件名时,异步将数据写入文件,如果文件已存在则替换文件;追加内容
// 写入文件内容
const fs = require("fs");
fs.writeFile("./files/toBeWrite.txt", "Hello node.js!", function (err) {
  if (err) { return console.log("文件写入失败" + err.message); } // 文件写入成功 err 值等于 null, 文件写入失败,则 err 的值为错误对象
  console.log("文件写入成功");
});
// 追加文件内容
const fs = require("fs");
fs.writeFile("./files/toBeWrite.txt", "\r\nHello node.js!", {flag: "a"}, function (err) {
  if (err) { return console.log("文件写入失败" + err.message); } // 文件追加成功 err 值等于 null, 文件追加失败,则 err 的值为错误对象
  console.log("文件追加成功");
});
  • fs.appendFile(path, data[, options], callback) => 将数据异步附加到文件,如果文件尚不存在则创建文件。data可以是字符串或<Buffer>。
// 追加文件内容
const fs = require("fs");
fs.appendFile("./files/toBeWrite.txt", "\r\n'data to append'", function (err) {
  if (err) { return console.log("文件追加失败" + err.message); } // 文件写入成功 err 值等于 null, 文件写入失败,则 err 的值为错误对象
  console.log("文件追加成功");
});

文件描述符 file descriptors fd => POSIX 系统对于每个进程,内核都维护着一张当前打开的文件和资源表格。每个打开的文件都分配了一个称为文件描述符的简单数字标识符。在系统层,所有文件系统操作都使用文件描述符标识和跟踪特点的文件。为简化用户工作,Node 抽象出操作系统之间差异,并为所有打开文件分配一个数字型的文件描述符。

const fs = require("fs");
const path = require("path");
const fdfilepath = path.resolve(__dirname + "/files/fd.txt");
fs.open(fdfilepath, (err, fd) => {
  if (err) { return; }
  fs.fstat(fd, (err, info) => {});
});
const content = "hello fd";
fs.writeFile("./files/fd.txt", content, { flag: "a" }, (err) => { console.log(err); });
fs.readFile("./files/fd.txt", null, (err, data) => { console.log(data.toString()); }); // <Buffer> => 16进制是二进制的一种表现形式

Path

path 模块提供用于处理文件和目录路径的方法,屏蔽不同环境下分隔符的差异。

  • path.basename(path[, ext]) => 返回最末尾分隔符的后续部分;ext -> 可选的文件扩展名,区分大小写
var path = require("path");
console.log(path.extname("/a/b/c.java"), path.basename("/a/b/c.java"), path.basename("/a/b/c.java", '.java')) // .java c.java c
  • path.delimiter => 提供特定于平台的路径分隔符
var path = require("path");
console.log(process.env.PATH.split(path.delimiter)) // [...]
  • path.dirname(path) => 返回路径目录名称
var path = require("path");
console.log(path.dirname('/a/b/c')) // /a/b
  • path.extname(path) => 返回路径拓展名
var path = require("path");
console.log(path.extname('index.coffee.md')) // .md
  • path.normalize(path) => 规范化给定的路径 -> 多个连续的路径段分隔符会被特定于平台的路径段分隔符的单个实例替换
var path = require("path");
var pathStr = path.normalize("a/b//c///d\\\e");
console.log(pathStr); // a/b/c/d\e
  • path.parse(path) => 返回对象,其属性表示路径有效元素
var {parse} = require("path");
console.log(parse("/a/b/c.java")) // { root: '/', dir: '/a/b', base: 'c.java', ext: '.java', name: 'c' }
  • path.format(pathObject) => 把对象转化为一个路径字符串
var {format} = require("path");
console.log(format({ root: '/', dir: '/a/b', base: 'c.java', ext: '.java', name: 'c' })) // /a/b/c.java
  • path.resolve([...paths]) => 将一系列路径或路径段解析为绝对路径
var path = require('path') // 若不传入 path 则返回当前工作目录的绝对路径
console.log(path.resolve('a/b/c')) // 拼接当前的目录 => /Users/xieziyi/a/b/c
  • path.join([...paths]) => 使用特定于平台的分隔符作为分隔符将所有给定的段连接
var {join} = require('path')
console.log(join('a','/b','../c','/d')) // a/c/d

两者都有连接字符串的效果,区别在于前者会判断拼接字符串路径是否包含以 / 或 ./ 或 ../ 开头的路径分隔符,并以路径分隔符拼接当前文件所在的路径。

以 / 开头 resolve 和 join 效果一致,./ 或 ../ 则会拼接上当前文件所在位置路径。

  • path.sep => 提供特定于平台的路径段分隔符
var path = require("path");
console.log('a/b/c'.split(path.sep)); // [ 'a', 'b', 'c' ]

Http

不同于 Tomcat 或 Apache 等容器,http 模块具有创建服务器的功能。

  • http.createServer([options][, requestListener]) => 创建 Web 服务器
const http = require('node:http');
const server = http.createServer((req, res) => { // Create a local server
  // 指定编码对于 JSON 来说有些多余 => 因为 JSON 的默认编码是 UTF-8
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(JSON.stringify({ data: 'Hello World!' }));
});
server.listen(8000);
  • request.setHeader(name, value) => 为标头对象设置单个标头值
const http = require('node:http');
const server = http.createServer((req, res) => {
  const body = 'hello world';
  res.setHeader("Content-Length", body.length);
  res.setHeader("Content-Type", "text/plain");
  res.setHeader("Set-Cookie", "type=ninja");
  res.statusCode = 200; // response.statusCode => 使用隐式标头时将发送给客户端的状态代码
  res.statusMessage = 'ok'
  res.end(body);
});
server.listen(8000);
  • response.writeHead(statusCode[, statusMessage][, headers]) => 向请求发送响应标头
const http = require('node:http');
const server = http.createServer((req, res) => {
  const body = 'hello world';
  res.writeHead(200, {
    'Content-Length': Buffer.byteLength(body),
    'Content-Type': 'text/plain'
  })
  .end(body);
});
server.listen(8000);
  • url.parse(urlString[, parseQueryString[, slashesDenoteHost]]) => 解析 URL 字符串 -> 路径参数是字符串类型,需要解析、分隔转成对象
var http = require('http');
var url = require('url');
http.createServer(function (req, res) {
  var urlObj = url.parse(req.url, true);
  var queryObj = urlObj.query;
  res.writeHead(200, {'Content-Type': 'text/html'});
  res.write(queryObj.name);
  res.end();
}).listen(8080);
const http = require("http");
const port = 8089;
http
  .createServer((req, res) => {
    let data = "";
    req.on("data", (chunk) => {
      data += chunk;
    });
    req.on("end", function () {
      let method = req.method,
        headers = JSON.stringify(req.headers),
        httpVersion = req.httpVersion,
        requestUrl = req.url;
      res.writeHead(200, { "content-type": "text/plain;charset=utf-8" });
      let responseData =
        method + "-" + headers + "-" + httpVersion + "-" + requestUrl;
      res.end(responseData);
    });
  })
  .listen(port, () => {
    console.log(`server is running at http://127.0.0.1:${port}`);
  });
  • 原生 Node 托管静态资源 => index.html
const fs = require("fs");
const http = require("http");
const path = require("path");
const server = http.createServer();
server.on("request", (req, res) => {
  let fpath = "";
  if (req.url === "/") {
    fpath = path.join(__dirname, "./clock/index.html");
  } else {
    fpath = path.join(__dirname, "/clock", req.url);
  }
  fs.readFile(fpath, "utf8", (err, dataStr) => {
    if (err) return res.end("404 Not found");
    res.end(dataStr);
  });
});
server.listen(80, () => {
  console.log("server running at http://127.0.0.1");
});

Stream

A stream is an abstract interface for working with streaming data in Node.js. The node:stream module provides an API for implementing the stream interface.

There are many stream objects provided by Node.js. For instance, a request to an HTTP server and process.stdout are both stream instances.

Streams can be readable, writable, or both. All streams are instances of EventEmitter.

通常开发中不会直接使用偏底层的流模块,而是选择其的二次封装。http 中的 res 和 req 流对象。文件转化成流对象 => fs 模块的 createReadStream 和 createWriteStream;zlib 和 crypto 模块是对转化流的应用。

stram 主要用于 IO 操作 -> 网络请求、文件处理,处理端到端数据交换

Node 中处理数据的传统模式是缓冲模式,即程序将所需处理得资源从磁盘全部加载入内存缓冲区,待所有数据全部加载后,再进行后续处理。在流模式下,程序只要加载到数据就会立即进行处理,资源将会被切块传递给调用方。后者占用内存更小,且调用端可能快得到相应。

Node 中的流模块主要用作向其他模块提供流接口的 API。流在结构上可分为可读流 Writable、可写流 Readabale、(可读可写)双工流 Duplex 和转换流 Transform。

Readable 实现 readable、resume、error、data、end、close 事件;Writable 实现 close、finish、drain、error 事件;Duplex 派生 transform 和 passThrough 流。

流之间的交互上实现了 pipe 管道

/* Readable 可读流 */
const { Readable } = require("stream");
new Readable({
  highWaterMark: 16 * 1024, // 可读流缓冲区最大容量
  encoding: null,
  objectMode: false, // JS 对象解析为可读流 => 一般情况下流对象只处理字符串和 buffer
  read: function () {}, // 将数据推送至缓冲区 => 内部调用
});
class myReadableStream extends Readable {
  constructor(options, data) {
    super(options); // options 用于初始化父类的构造函数
    this.data = data;
  }
  _read() {
    // 往缓冲区推数据 => 此私有方法不可 static
    this.push(this.data);
    this.push(null); // null => 信号 -> 可读流结束
  }
}

const r = new myReadableStream(
  { encoding: "utf-8" },
  "Readable Stream Test Ok"
);
// r.on("readable", () => { console.log(r.read()); }); // 此 read 是向缓冲区读取数据
// r.on("readable", () => { // 非流动模式 => 调用 read() => 自由度高
//   let chunk;
//   while ((chunk = r.read(1))) {
//     console.log(chunk);
//   }
// });
r.on("data", (chunk) => { console.log(chunk)}); // 流动模式
r.on("end", () => { console.log("Read Data End")}); // end 事件 => 监听可读流完全消费后执行回调
r.on("error", () => { console.log("Read Data Error")}); // error 事件 => 捕获可读流出现的错误
/* Writable 可写流 */
const { Writable } = require("stream");
const fs = require("fs");
const writableTestObj = {
  path: "./writableTestObj.txt",
  content: "writableTestObj",
};
class myWritableStream extends Writable {
  constructor(options) {
    super({ ...options, objectMode: true });
  }
  _write(chunk, encoding, cb) {
    fs.writeFile(chunk.path, chunk.content, { encoding }, (err) => {
      cb();
    });
  }
}
const w = new myWritableStream();
w.on("finish", () => {
  console.log("DONE");
});
w.on("error", () => {
  console.log(err);
});
w.write(writableTestObj);
w.end();
/**
 * 前端资源保存服务器
 * http module => req 可读流、res 可写流
 */
const http = require("http");
const fs = require("fs");
const server = http.createServer((req, res) => {
  const w = fs.createWriteStream("./w.jpeg");
  req.on("data", (chunk) => { // 可写流 end 事件不能写在 data 事件中 => 文件会多次触发 data 事件
    w.write(chunk);
  });
  req.on("end", () => {
    w.end();
    res.end("SAVE SUCCESS");
  });
  w.on("error", (err) => {
    console.log(err);
    res.end("SAVE FAILED");
  });
});
server.listen(3000);

pipe 是 Node 为 Stream 实现的接口,连接可读流与可写流 => 可读流.pipe(可写流),可读流数据自动进入可写流。数据流动由 pipe 管理,无需手动调用 read 和 write 方法。

/**
 * 前端资源保存服务器
 * http module => req 可读流、res 可写流
 */
const http = require("http");
const fs = require("fs");
const { pipeline } = require("stream");
const server = http.createServer((req, res) => {
  const w = fs.createWriteStream("./w.jpeg");
  pipeline(req, w, (err) => {
    console.log(err);
    res.end("SAVE FAILED");
  });
  res.end("SAVE SUCCESS");
});
server.listen(3000);

转换流内部分别实现可写缓冲区和可读缓冲区,可写缓冲区对应外部的可读流,通过 wirte 方法转换流,将可读流数据写入内部的可写缓冲区;转换流的可写缓冲区对应外部的可写流,通过监听 data 事件,可写流可以获取到可读缓冲区的内容。在转换流内部通过私有的 _transform 方法传递数据。转换流的核心就是 _transform 方法的实现。在这个函数中可对数据进行操作,并将转换后的数据通过 push 方法推送到可读缓冲区。

/* 文件拷贝且字母大写 */
const {Transform, pipeline} = require("stream");
const fs = require("fs");
class myStream extends Transform{
  constructor(options){
    super(options)
  }
  _transform(chunk, encoding,cb){
    this.push(chunk.toString().toUpperCase());
    cb();
  }
}
const r = fs.createReadStream("./r.txt");
const w = fs.createWriteStream("./w.txt");
const t = new myStream();
// r.pipe(t).pipe(w)
pipeline(r, t, w, (err) => {console.log(err);})

转换流除 transform 方法还有 flush 方法,其会在整个数据流结束之前被调用。这就提供了接口,可以在数据流的尾部向可写流推送额外数据。

/* 过滤数据 => 将符合条件的数据写入指定文件且计算通过率 */
const { Transform, pipeline, Readable } = require("stream");
const fs = require("fs");
const testArr = [
  { name: "A", id: 1 },
  { name: "B", id: 2 },
  { name: "C", id: 3 },
];
class myStream extends Transform {
  constructor(options) {
    super({ ...options, objectMode: true });
    this.counter = 0;
    this.total = 0;
  }
  _transform(chunk, encoding, cb) {
    this.total += chunk.length; // 遍历数组的长度
    for (let item of chunk) {
      if (item.id > 1) {
        this.push(JSON.stringify(item));
        this.push("\n");
        this.counter++;
      }
    }
    cb();
  }
  _flush(cb) {
    this.push("---by ok---");
    this.push("\n");
    this.push(`---${this.counter}/${this.total}---`);
    cb();
  }
}

const r = new Readable({
  objectMode: true,
  read() {
    this.push(testArr);
    this.push(null);
  },
});
const t = new myStream();
const w = fs.createWriteStream("./w.txt");
pipeline(r, t, w, (err) => {
  console.log(err);
});

Buffer

缓冲区类似一个整数数组,其元素为十六进制的两位数。在计算机中的二进制都会以十六进制显示,所以尽管存储的是二进制,显示的却还是十六进制。

Buffer 创建内存中空间的元素范围是 00 - ff,所以实际上一个元素就表示内存中的一个字节。

00-ff => 0-255 => 00000000-11111111 => 8bit => 1byte

Buffer 的内存不是通过 JS 分配,而是在底层通过 C++ 申请的,是对应 V8 堆内存之外的一块原始内存。

缓冲区用于操作二进制数据。后端通常需要读取和操作长文本,处理前端传递的图片和大文件,而这些对象都是二进制的。前端对二进制数据的操作需求不多,可以选择字符串来进行处理,但字符串是不可变的,所有对字符串的操作都会生成新字符串,而这种结果在耗时的同时还会占用内存。缓冲区的二进制数据可以像操作数组般的直接操作数据源。

  • Buffer.alloc(size[, fill[, encoding]]) => 返回 Buffer 实例

缓冲区的大小一旦确定则不能修改,实际上是对内存的直接操作,在内存中分配出连续的长度作为确定的空间。与数组不同,但内存空间不连续会导致性能较差。

const buf1 = Buffer.alloc(10); // 创建一个长度为 10、且用 0 填充的 Buffer
const buf2 = Buffer.alloc(10, 1); // 创建一个长度为 10、且用 0x1 填充的 Buffer 
  • Buffer.from(?) => 返回 Buffer 实例

Buffer.from(?) 和 Buffer.alloc(size) 都可新建 Buffer 实例,前者是静态初始化,后者是动态初始化。

console.log(Buffer.from('Hello ok').length) // 占用内存大小;一个英文占1字节 - 8
console.log(Buffer.from('Hello 紫').length) // 占用内存大小;一个汉字占3字节 - 9
const buf4 = Buffer.from([1, 2, 3]); // 创建包含 [0x1, 0x2, 0x3] 的 Buffer
console.log(Buffer.from(Buffer.from('Hello zs')), Buffer.compare(Buffer.from('Hello zs'), Buffer.from(Buffer.from('Hello zs')))) // <Buffer 48 65 6c 6c 6f 20 7a 73> 0 => 拷贝传入的 Buffer 实例数据
const buf5 = Buffer.from('tést'); // 创建包含 UTF-8 字节 [0x74, 0xc3, 0xa9, 0x73, 0x74] 的 Buffer
const buf6 = Buffer.from('tést', 'latin1'); // 创建包含 Latin-1 字节 [0x74, 0xe9, 0x73, 0x74] 的 Buffer

动态初始化 => 数组的定义和分配空间赋值的操作分开进行;静态初始化 => 数组在初始化时显式指定每个数组元素的初始值。

  • Buffer.allocUnsafe(size) => 返回 Buffer 实例

Buffer.allocUnsafe(size) 返回一个指定大小的 Buffer 实例,此方法比调用既分配空间又清空数据的 Buffer.alloc() 更快。但是 Buffer 实例不会被初始化则可能包含敏感的旧数据,需要使用 fill() 或 write() 重写。

const buf3 = Buffer.allocUnsafe(10); // 创建一个长度为 10 且未初始化的 Buffer

Buffer.allocUnsafeSlow(size) 适用情况 => 不确定的时刻从池中保留一小块内存

  • buf.write(string[, offset[, length]][, encoding]) => 将字符串写入 buf

  • buf.toString([encoding[, start[, end]]]) => 将 Buffer 实例解码成字符串

Events

所有流都是 EventEmitter 的实例。EventEmitter 主要作用是在 Node 中提供一种函数调用的模式,即观察者模式,给事件注册一个或多个监听器。EventEmitter 核心 API => on 注册监听器、once 注册一次性监听器、emit 触发事件,同步调用监听器、removeListener 移除某事件监听器。

const {EventEmitter} = require("events");
const ee = new EventEmitter();
ee.on("eventTest",()=>{console.log("ok");});
ee.on("eventTest",()=>{console.log("okk");});
ee.prependListener("eventTest",function yes(){console.log("yes");}) // prependListener => 监听器插入数组开头
ee.emit("eventTest"); // 同步调用 => 监听器注册顺序就是执行顺序 => 要求在触发前注册好监听器
console.log(ee.listeners("eventTest"));
const {EventEmitter} = require("events");
const ee = new EventEmitter();
process.nextTick(()=>{ // nextTick 执行会延迟到同步代码之后
  ee.emit("eventTest")
})
ee.on("eventTest",()=>{console.log("ok");});
const { EventEmitter } = require("events");
const fs = require("fs");
function mySearch(param) {
  const ee = new EventEmitter();
  const r = fs.createReadStream("./r.txt", { encoding: "utf-8" });
  r.on("data", (chunk) => {
    if (chunk.match(new RegExp(param))) {
      ee.emit("found");
    } else {
      ee.emit("unfound");
    }
  }).on("error", () => {
    ee.emit("error", error);
  });
  return ee;
}
module.exports = mySearch;
const ms = require("./utils");
ms("okk").on("found",()=>{console.log("found");}).on("unfound",()=>{console.log("unfound");}).on("error",(err)=>{console.log(err);})

Crypto

The crypto module provides cryptographic functionality that includes a set of wrappers for OpenSSL's hash, HMAC, cipher, decipher, sign, and verify funcs.

message digest algorithm 消息摘要算法或 hash function 散列函数是将任意长度的输入数据映射到固定长度的输出的过程。输出通常被称为散列值、散列码或消息摘要。

  • crypto.createHash(algorithm[, options]) => 创建并返回 Hash 对象

该 Hash 对象可用于以给定算法生成哈希摘要。可选选项参数控制流行为。

algorithm 取决于平台上 OpenSSL 版本所支持的算法。

$ openssl list-message-digest-algorithms
$ openssl list -digest-algorithms
$ openssl version
  • hash.update(data[, inputEncoding]) => 用给定的数据更新哈希

当 data 传入的是字符串,且未设置输入编码 inputEncoding 时,默认使用 utf-8。

当 data 传入的是 Buffer、TypedArray 或 DataView 时,inputEncoding 可忽略。

update 方法可以多次调用,以更新摄取流数据,例如来自文件读取流的缓冲区。

  • hash.digest([encoding]) => 计算数据的摘要

encoding 可以是 hex、latin1 或 base64。v6.4.0 中将 latin1 作为 binary 的别名。

提供 encoding 会返回字符串,否则返回 Buffer 实例。

调用完 digest 方法后的 Hash 对象不可再使用,否则会抛出异常。

const crypto = require('crypto');
const md5 = crypto.createHash('md5');
const message = 'hello crypto';
const digest = md5.update(message, 'utf8').digest('hex'); 
console.log(digest, digest.length); // 2384190895f6fa3de5b7c458532c8d75 32

HMAC 有时扩展为 keyed-hash message authentication code 密钥散列消息认证码,或 hash-based message authentication code 散列消息认证码,是一种通过特别计算方式之后产生的消息认证码 MAC。使用密码散列函数,同时结合一个加密密钥。可以用来保证资料的完整性,同时可以用来作某个消息的身份验证。

  • crypto.createHmac(algorithm, key[, options]) => 返回特定的 Hmac 对象

创建并返回使用给定算法和密钥的 Hmac 对象。可选选项参数控制流行为。

const crypto = require("crypto");
const hmac = crypto.createHmac("sha256", "secret key");
const res = hmac.update("hello hmac").digest("hex");
console.log(res);

key 用于生成加密的 HMAC 哈希。若 key 是 KeyObject,其 type 必须为 secret。

// hmac.js
const { createReadStream } = require("fs");
const { createHmac } = require("crypto");
const { argv } = require("process");
const filename = argv[2];
const hmac = createHmac("sha256", "hello hmac");
console.log(hmac);
const input = createReadStream(filename);
input.on("readable", () => {
  const data = input.read();
  if (data) hmac.update(data);
  else {
    console.log(`${hmac.digest("hex")} ${filename}`);
  }
});
$ node "/Users/.../hmac.js" hmac.js
Hmac {
  _options: undefined,
  [Symbol(kHandle)]: Hmac {},
  [Symbol(kState)]: { [Symbol(kFinalized)]: false }
}
961f862e87d5ef0bf2b4d0d826157473484b20caa7dca746e2764624dee9b00d hmac.js
  • crypto.randomBytes(size[, callback]) => 生成加密的强伪随机数据

size 为生成的字节数,不得大于 2 ** 31 - 1。

回调函数未提供时,同步生成随机字节并以缓冲区返回;回调函数提供时,会被异步调用生成字节。该回调函数包含 err 和 buf 两个参数。

err 为错误发生时的 Error 对象,buf 为生成字节的缓冲区。

const { randomBytes } = require('crypto');
randomBytes(256, (err, buf) => {
  if (err) throw err;
  console.log(`${buf.length} bytes of random data: ${buf.toString('hex')} => Async`);
});
const buf = randomBytes(256);
console.log(`${buf.length} bytes of random data: ${buf.toString('hex')} => Sync`);
  • Public-key cryptography & Symmetric-key algorithm

在明文和密文之间通过指定算法互相转换时,可通过引入密钥来增强安全性。根据加密和解密时所用的秘钥是否相同,可以将加密算法分为对称加密与非对称加密。

公开密钥加密也称为非对称式加密,其公钥用作加密,私钥则用作解密。公钥可任意向外公开或发布;私钥不可以公开,必须严格保管。RSA、ElGamal、DSA...

对称密钥加密要求在加密和解密时使用相同的密钥。对称加密的速度比公钥加密快很多。AES、DES、Blowfish、IDEA...

  • crypto.createCipheriv(algorithm, key, iv[, options]) => 返回 Cipher 对象

使用给定的算法、密钥和初始化向量创建并返回一个 Cipher 对象。

The key is the raw key used by the algorithm and iv is an initialization vector. Both arguments must be 'utf8' encoded strings, Buffers, TypedArray, or DataViews. The key may optionally be a KeyObject of type secret. If the cipher does not need an initialization vector, iv may be null.

初始化向量应该是不可预测且唯一的,在理想情况下甚至是加密随机的。初始化向量不必严格保管,通常会添加到未加密的明文。

AES 的区块长度固定为 128 比特,密钥长度则可以是 128,192 或 256 比特。

  • crypto.createDecipheriv(algorithm, key, iv[, options])

通过指定条件创建并返回一个 Decipher 对象。可选参数 options 控制流的行为。

  • cipher.update(data[, inputEncoding][, outputEncoding])

通过 data 更新 Cipher 对象。inputEncoding 指定时,data 是字符串类型。未提供 inputEncoding 时,data 必须时 Buffer、TypedArray 或者 DataView。若 data 是 Buffer、TypedArray 或者 DataView,输入编码格式可忽略。

输出编码格式指定时返回字符串;未提供输出编码格式则会返回一个缓冲区。

update 方法可在 final 方法前调用多次并传入新的 data。final 方法调用后再执行 update 会抛出异常。

const crypto = require("crypto");
const key = crypto.randomBytes(192 / 8);
const iv = crypto.randomBytes(128 / 8);
const algorithm = "aes192";
function encrypt(text) {
  const cipher = crypto.createCipheriv(algorithm, key, iv);
  cipher.update(text);
  return cipher.final("hex");
}

function decrypt(encrypted) {
  const decipher = crypto.createDecipheriv(algorithm, key, iv);
  decipher.update(encrypted, "hex");
  return decipher.final("utf8");
}

const content = "hello";
const crypted = encrypt(content);
const decrypted = decrypt(crypted);
console.log(crypted, decrypted);
  • cipher.final([outputEncoding])

返回 Cipher 对象的值。指定 outputEncoding 时返回字符串,否则返回缓冲区。

final 方法被调用后,Cipher 对象不再用作加密数据,重复调用多次 final 方法会抛出异常。

  • crypto.scryptSync(password, salt, keylen[, options])

返回缓冲区。提供同步的 scrypt 实现。scrypt 是一个基于密码的密钥派生函数。

In cryptography, a salt is random data that is used as an additional input to a one-way function that hashes data, a password or passphrase. Salts are used to safeguard passwords in storage. wiki

尽可能的选择具有唯一性的 salt,推荐 salt 是至少 16 bytes 的随机数。

keylen 表示密钥的长度,必须是数字。

const { scryptSync } = require("crypto");
const key1 = scryptSync("hello password", "hello salt", 64);
const key2 = scryptSync("hello password", "hello salt", 64, { N: 256 });
console.log(key1.toString("hex"), key2.toString("hex"));

Process

  • process.argv => 返回命令行启动 Node 进程时所传递的参数数组

数组元素依次为 process.execPath,正在执行的文件路径和附加的命令行参数。

const { argv } = require('process');
argv.forEach((val, index) => {
  console.log(`${index}: ${val}`);
});

process.argv0 属性存储 Node 启动时传递 argv[0] 的原始值的只读副本。

process.execPath.split("/").pop() == process.argv0

Configuration Template

Routing and Forwarding

在使用 Vue 的 History 路由模式时,前端路由会负责处理所有路由的匹配和渲染。当用户输入一个地址或点击链接进行页面跳转时,如果后端服务器没有对应的资源,就会将请求转发到前端入口页面(通常是 index.html)。前端路由会根据用户输入的地址进行匹配,并根据匹配结果渲染相应的组件。如果没有找到匹配的路由,就会渲染 404 页面,从而实现前端路由处理 404 错误的功能。此外,也可以使用 connect-history-api-fallback 中间件来实现相同的功能。

const express = require('express');
const path = require('path');

const app = express();
const port = 3000;

// 静态文件服务
app.use(express.static(path.join(__dirname, 'public')));

// 所有路由请求返回Vue应用的入口页面
app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

// 启动服务器
app.listen(port, () => {
  console.log(`Server is running on port ${port}`);
});

Framework

Koa2

Express 内置捆绑许多函数,而 Koa 轻量到只剩基本功能,当需要的时候再通过适合的中间件 Middleware 来搭配组合。可通过 koa-generator 脚手架快速搭建。

  • Hello World
$ npm install --save koa

引入 Koa 模块并创建 Koa 实例 app。Web 请求会经过 app.use() 函数的处理。

const Koa = require('koa');
const app = new Koa();
app.use(async ctx => { ctx.body = 'Hello World'; });
app.listen(3000);
  • Koa 洋葱模型 onion model

await next() divides each middleware into pre-operation, other middleware operations and post-operation.

代码运行到 next() 会暂停当前,执行后续中间件。next() 返回 Promise 的实例,使用 await 是欲以同步的方式等待 Promise 实例的执行完成。嵌套的 Promise 就像洋葱的模型,直到 await 返回最内层 Promise 的 resolve 值。

Promise.resolve(middleware1(context, async() => {
  return Promise.resolve(middleware2(context, async() => {
    return Promise.resolve(middleware3(context, async() => {
      return Promise.resolve();
    }));
  }));
}))
.then(() => { console.log('end'); });
const Koa = require('koa');
let app = new Koa();

const middleware1 = async (ctx, next) => { console.log(1); await next(); console.log(6); }
const middleware2 = async (ctx, next) => { console.log(2); await next(); console.log(5); }
const middleware3 = async (ctx, next) => { console.log(3); await next(); console.log(4); }

app.use(middleware1).use(middleware2).use(middleware3);
app.use(async(ctx, next) => { ctx.body = 'hello world' })
app.listen(3000)
// Output 1, 2, 3, 4, 5, 6
const Koa = require('koa');
let app = new Koa();

const p = function(args) {
  return new Promise(resolve => { setTimeout(() => { console.log(args); resolve(); }, 100); });
};

const middleware1 = async (ctx, next) => {
  await p(1);
  // await next();
  next();
  console.log(6);
};

const middleware2 = async (ctx, next) => {
  await p(2);
  // await next();
  next();
  console.log(5);
};

const middleware3 = async (ctx, next) => {
  await p(3);
  // await next();
  next();
  console.log(4);
};

app.use(middleware1).use(middleware2).use(middleware3);
app.use(async(ctx, next) => { ctx.body = 'hello world' })
app.listen(3000)
// Output: 1, 6, 2, 5, 3, 4

koajs/compose 模块实现中间件的执行。dispatch(0) 的执行表示首个中间件函数 fn 取 middleware[0]。middleware 数组维护的是通过 app.use() 压入的中间件。中间件执行时的传参分别是上下文和 next。

next() 执行即是调用 dispatch(i) 函数,所以遇见函数 next 的逻辑是执行下一个中间件。

$ npm install @koa/router
const Koa = require('koa');
const Router = require('@koa/router');
const app = new Koa();
const router = new Router();
router.get('/', async (ctx, next) => {
  console.log("@koa/router get request => ",`${ctx.method} ${ctx.url}`)
  ctx.body = 'Hello World';
  await next();
});
app.use(router.routes()).use(router.allowedMethods());
app.listen(3000);

Koa 是先经过业务路由,再处理中间件;而 Express 是先经过中间件,如果中间件验证不通过就不会处理业务。app.use(bodyParser()); 放在路由处理之前。

$ npm install koa-bodyparser

@koa/cors 根据简单请求、预检请求分别对 CORS 头进行不同的处理。

$ npm install @koa/cors --save
// 结合 @koa/cors 与 koa-bodyParser
var Koa = require('koa');
const cors = require('koa-cors');
var bodyParser = require('koa-bodyparser'); // 获取post请求的参数
var app = new Koa();
app.use(bodyParser()).use(cors());

app.use(async ctx => {
  // the parsed body will store in ctx.request.body
  // if nothing was parsed, body will be an empty object {}
  ctx.body = ctx.request.body;
});

app.listen(3000);
$ npm install koa-static
$ npm install koa-mount
const Koa = require("koa");
const path = require("path");
const server = require("koa-static"); // 搭建静态服务
const mount = require("koa-mount"); // 指定静态服务的请求前缀
let app = new Koa();

const staticPath = path.resolve(__dirname, "static");
const staticServer = server(staticPath, {
  setHeaders: (res, path, stats) => {
    if (path.indexOf(/[jpg|png|gif|jpeg]/) !== -1) {
      console.log(stats);
      res.setHeader("Cache-Control", ["private", "max-age=60"]);
      res.setHeader("Test-Static-Stats-Birthtime", stats.birthtime); // 自定义响应头
    }
  },
});

app.use(mount("/supdir", staticServer));
app.listen(3000);

Third Party Modules

project development

analysis test

结束

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议,转载请注明出处!

]]>
<![CDATA[jenv => Java Environment]]>https://zairesinatra.github.io//jenv/614ccdb5f7263c9315a75845Sat, 19 Jun 2021 08:48:00 GMT

快速上手

Bye AdoptOpenJDK, Hello Adoptium!

  • Open JDK
jenv => Java Environment

当下 Java 开发是由 Oracle 主导,以 OpenJDK 开源项目的形式进行。Oracle 和社区提供的 OpenJDK 分别称为 Oracle's OpenJDK 和 AdoptOpenJDK

OracleJDK 只提供 6 个月的安全更新服务给 Oracle’s OpenJDK,AdoptOpenJDK 则对 OpenJDK 提供四年的长期支持版本 LTS。

  • Temurin

Homebrew's AdoptOpenJDK 最高只到 16,后续的开发已从 AdoptOpenJDK 迁移到 Eclipse Adoptium。Eclipse 基金会的 Adoptium 提供 Java 生态系统和运行时的相关技术,其子项目 Eclipse Temurin 是基于 OpenJDK 的开源 JavaSE 构建。

破旧立新

  • 卸载 AdoptOpenJDK

终端查看安装的 JDK 版本,并以管理员身份进行卸载,随后将 ~/.zshrc 中的相关配置注释。

以 Homebrew 卸载时要将 untap 操作置于最后执行,否则会出现 Error: Refusing to untap adoptopenjdk/openjdk because it contains the installed formulae...

若不是通过 Homebrew 安装的 JDK,则会出现提示 Error: Cask 'adoptopenjdk16' is not installed. => 只要将 Homebrew 安装的卸载干净即可 untap。

# 方式一 =>
$ ls /Library/Java/JavaVirtualMachines/ # 普遍 Java 安装存放位置
jdk-11.0.11.jdk
$ sudo rm -rf /Library/Java/JavaVirtualMachines/jdk-11.0.11.jdk
# 方式二 =>
$ brew remove --cask adoptopenjdk8 # 卸载 adoptopenjdk
$ brew untap AdoptOpenJDK/openjdk # untap => 避免 Error: Cask xxx exists in multiple taps
  • 安装 Temurin
$ brew tap homebrew/cask-versions # tap 能扩大 casks 范围
$ brew install -- cask temurin8 # 位置 => /Library/Java/JavaVirtualMachines/
  • 安装 jenv => 可能将存在的 iterm2 配置损坏

根据 jenv doctor 输出结果可观察到 jenv 已正确加载但尚未安装 Java

$ mkdir ~/.jenv # Create Dir => 官方指导没有这一步
$ brew install jenv
$ jenv doctor # To verify jenv was installed => 下方代码是正确情况
[OK]	No JAVA_HOME set
[ERROR]	Java binary in path is not in the jenv shims.
[ERROR]	Please check your path, or try using /path/to/java/home is not a valid path to java installation.
	PATH : /.../.../:/.../.../:/.../.../:/.../.../:/.../.../:/.../.../...
[OK]	Jenv is correctly loaded

若出现如下情况则表示存在配置名占用 => 需要卸载 jdk

[ERROR] JAVA_HOME variable already set, scripts that use it directly could not use java version set by jenv [ERROR] Java binary in path is not in the jenv shims. 

确保 JAVA_HOME 已设置,应确保启用 export 插件(根据情况可忽略这一步)

$ jenv enable-plugin export
$ exec $SHELL -l
  • 添加 Java 环境

默认情况下,当前环境最新版本的 Java 是 system 在 macOS 上的默认版本。通过 jenv local VERSION 可以为当前工作目录设置一个本地 Java 版本。同时会伴生创建一个 .java-version 文件,目的是为项目检入 Git,且启动 shell 时让 jenv 确保加载此 java。

# 注意通过 brew 安装的 jdk 存在于 /usr/local/Cellar/... => 已过时
$ jenv add /usr/local/Cellar/openjdk@11/11.0.12
11.0.12 added
11.0 added
11 added
$ brew install openjdk@8
$ jenv add /usr/local/Cellar/openjdk@8/1.8.0+302
...
# 2022/03/03 更新 => /Library/Java/JavaVirtualMachines/temurin-8.jdk/Contents/Home
# Home 结束 => 否则会出现 $PATH is not a valid path to java installation 的 Error
$ jenv add /Library/Java/JavaVirtualMachines/temurin-8.jdk/Contents/Home
openjdk64-11.0.12 added
temurin64-1.8.0.332 added
1.8.0.332 added
1.8 added
1.8.0.332 already present, skip installation
$ jenv doctor # 检查 jenv 显示正确                          
[OK]	JAVA_HOME variable probably set by jenv PROMPT
[OK]	Java binaries in path are jenv shims
[OK]	Jenv is correctly loaded
$ brew install -- cask temurin11
$ jenv add /Library/Java/JavaVirtualMachines/temurin-11.jdk/Contents/Home
$ brew install -- cask temurin17
$ jenv add /Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home
  • 开发版本配置

配置方面除开版本名外并无明显操作区别,本段过程以旧版本 openjdk 为主。

$ jenv versions # View Java environment version list
* system (set by /Users/zsxzy/.jenv/version) # 旧版本 => 废弃
  1.8
  1.8.0.302
  11
  11.0
  11.0.12
  openjdk64-1.8.0.302
  openjdk64-11.0.12
$ jenv local 1.8.0.302 # Java environment selection
$ exec $SHELL -l # Java environment selection
$ cat .java-version # Java environment selection
1.8.0.302
$ echo ${JAVA_HOME} # Check if it is set
/Users/zsxzy/.jenv/versions/1.8.0.302
$ jenv global 1.8.0.302 # Global Java version settings
$ java -version
openjdk version "1.8.0_302"
OpenJDK Runtime Environment (build 1.8.0_302-bre_2021_08_14_21_34-b00)
OpenJDK 64-Bit Server VM (build 25.302-b00, mixed mode)
# Shell Java version setting
$ jenv shell 11 # 当前 shell 会话使用指定设置的 Java
$ jenv which java # 显示可执行的 Java 的完整路径
$ rm .java-version # Delete .java -version which u don't need

在设置完 Java 版本后,可通过 Java8 的 java -version 或 Java 9+ 的 java --version 查看版本。

补漏订讹

AdoptOpenJDK 与 tap

Homebrew 默认只有两个仓库 Formulae 与 Casks,可以使用 tap 指令添加更多仓库,search 到更多的内容。OpenJDK releases 需要在 tap 中查看,意味着是从 brew casks 中分离的部分。通过 brew tap add 指定仓库进行添加。

$ brew tap AdoptOpenJDK/openjdk
$ brew search /adoptopenjdk/
# 安装需要的 jdk 版本
$ brew install adoptopenjdk11
$ brew install adoptopenjdk8
# 注册 jenv
$ jenv add /Library/Java/JavaVirtualMachines/adoptopenjdk-11|8.jdk/Contents/Home
# 全局设置 JDK
$ jenv global 11
# 或者对于项目目录中的特定项目
$ cd ~/projects/my_project
$ jenv local 1.8
$ java -version
openjdk version "1.8.0_202"
# 取消版本设置
$ jenv global system
$ cd ~/projects/my_project
$ jenv local --unset

结束

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议,转载请注明出处!

]]>
<![CDATA[Vue3.x]]>https://zairesinatra.github.io//vue3/6162dab1583b7b9d2d04cd28Fri, 21 May 2021 14:22:00 GMT

快速上手

Vue3.x

声明式渲染 Declarative Rendering => 通过模板语法,以双花括号为占位符将数据插入到节点。
响应性 Responsiveness => 通过 Vue 自动跟踪 JS 的状态变化,在其发生改变时响应式地更新 DOM。
API 风格 API Styles => 选项式 API Options API、组合式 API Composition API。

选项式 API 与组合式 API

Options API 包含数据、方法、生命周期等选项,且选项中定义的属性会暴露在函数内部的 this 上。

封装复用组件不能完全解决因业务巨大所导致的逻辑关注点的冗长,这就会导致开发中必须不断地跳转相关代码的选项块。Composition API 将同类逻辑关注点的代码汇聚于 setup 组件选项。

Composition API 常配合 setup 函数来描述组件逻辑。因 setup 在 beforeCreate 钩子之前执行,此时的组件实例还未创建,所以在 setup 函数中不能使用 this,否则会出现 undefined。此外模板中需要使用的数据和函数,应在 setup 内进行返回。

这里对 setup 中无法使用 this 再做源码说明:

  • 调用 createComponentInstance 创建组件实例;
  • 调用 setupComponent 初始化 component 内部的操作;
  • 调用 setupStatefulComponent 初始化有状态的组件;
  • 在 setupStatefulComponent 取出 setup 函数;
  • 通过 callWithErrorHandling 的函数执行 setup;

上述代码可看出组件的实例 instance 肯定在执行 setup 函数之前就创建出来。

/* core/packages/runtime-core/src/errorHandling.ts */
export function callWithErrorHandling(
  fn: Function,
  instance: ComponentInternalInstance | null,
  type: ErrorTypes,
  args?: unknown[] // props, context
) {
  let res
  try {
    res = args ? fn(...args) : fn() // 未绑定 this
  } catch (err) {
    handleError(err, instance, type)
  }
  return res
}

在单文件组件中,组合式 API 也会与 <script setup> 搭配使用。<script setup> 是一种编译时语法糖,其中的导入和顶层变量/函数都能够在模板中直接使用。setup attribute 作为 hint,告诉 Vue 在编译时进行转换,推荐在 SFCs and Composition API 场景下使用。

SFCs => Vue Single-File Components(a.k.a. *.vue files, abbreviated as SFC) => 单文件组件能获得完整的语法高亮、CommonJS 模块以及组件作用域的 CSS。
SPA => single-page application 网络应用程序或网站的模型,通过动态重写当前页面来与用户交互,而非传统的从服务器重新加载整个新页面。

非单文件组件不能保证全局定义的组件名唯一,在字符串模板中缺乏语法高亮,不支持 CSS。其构建步骤中只能使用 Html 和 ES5 JavaScript,而不能使用 webpack 和预处理器 Babel。

<template>
  <div>Vue2 => 单文件组件</div>
</template>
<script>
export default { // 默认暴露
  name:'kebab-case|PascalCaseComponentName', // 不写则默认暴露单文件组件名 => Xxx.vue 的 Xxx
  data () { return { msg: '单文件组件' } }
}
</script>
<style></style>
Vue.component('kebab-case|PascalCaseComponentName', {
  data: function () {
    return { ... }
  },
  template: '<...>非单文件组件<... />'
})
<template>
  <h1>{{ msg }}</h1>
  <button @click="sayHi()">Hi Vue3</button>
</template>
<script>
export default {
  name: 'HelloWorld',
  setup(){
    const msg = "Hello Vue3";
    const sayHi = () => {
      console.log("Hi Vue3")
    };
    return { msg, sayHi }
  }
}
</script>
<template>
  <button @click="increment">Count is: {{ count }}</button>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const count = ref(0) // 响应式状态
function increment() { count.value++ } // 用来修改状态、触发更新的函数
onMounted(() => { console.log(`The initial count is ${count.value}.`) }) // 生命周期钩子
</script>

setup 作为组合式 API 的入口,是一个组件选项,在组件被创建之前且 props 被解析之后执行。setup 写法中可以通过 ...toRefs 方式将响应式对象中的每个属性转变为响应式数据,简化模板中的命名对象的指定。且 setup 返回的所有内容都会暴露给组件的其余部分 (计算属性、方法、生命周期等) 以及组件的模板。

import { reactive, toRefs } from "vue";
export default {
  name: "Test01",
  setup(){
    const data = reactive({
      name:'okk', age:20, func(){ console.log('Hello Vue3') }
    })
    return{ ...toRefs(data) } // => {{ name }}、{{ age }}、@<event>="func"
  }
}

setup 函数具有两个参数 props 和 context,props 是由上级组件所传递的属性组成的对象,context 对象包含 attrs,slots 和 emit 属性。

使用 <script setup> 时,声明的顶层绑定(声明变量、函数以及引入内容)都能在模板中直接使用,不需要返回。但若需将响应式对象中的每个属性都转换为响应式数据,那么需要借助 toRefs() 的解构。

当 <script setup> 与 <script> 标签同时存在时,后者 setup() 中定义的任何变量和方法都不能在模板进行访问。

<script setup>
import { reactive,toRefs } from "vue";
const data = reactive({ name:'okk', age:20, func(){ console.log('Hello Vue3') } })
const { name,age,func } = toRefs(data)
</script>

响应式数据

直接对变量进行字面量赋值的操作不会产生响应式的效果,所以在数据更新时不会驱动视图的更新。若要定义响应式数据,需要借助从 vue 导入的相关 function。定义响应式数据 => reactive()、ref();辅助函数 => toRef()、toRefs()。

ref 产生的响应式数据在修改和读取时应指定其 value;ref 产生的响应式数据在模板中使用时,可以省略 .value

除 null 和 undefined 的原始类型都有其相应的包装对象 => BigInt、Symbol、String、Number、Boolean

通过传入普通对象(非包装对象)作为参数创建响应式对象,若参数是字符串或数字则会报出警告,类似 React Hook 中的 useState()useReducer()。当直接从响应式数据对象中解构属性时,会造成响应式的丢失。

<template>
  <div>{{ countobj.count }}-<button @click="add">clickme add</button></div>
</template>
<script>
import { reactive } from 'vue'
export default {
  setup() {
    const countobj = reactive({ count: 0, });
    const add = () => { countobj.count++; };
    return { countobj, add, };
  },
};
</script>

reactive 源码位于 .../node_modules/@vue/reactivity/dist/reactivity.d.ts,其接受类型是泛型 T 的参数 target。T extends object => target 的类型是 object 类型或继承自 object 类的子类类型。返回值类型为 UnwrapNestedRefs<T>

Creates a reactive copy of the original object.
The reactive conversion is "deep"—it affects all nested properties. In the ES2015 Proxy based implementation, the returned proxy is not equal to the original object. It is recommended to work exclusively with the reactive proxy and avoid relying on the original object.
A reactive object also automatically unwraps refs contained in it, so you don't need to use .value when accessing and mutating their value:

type 关键字声明的类型 UnwrapNestedRefs<T> 通过判断 T 是否属于 Ref 或其子类,指定传入的 T 或者 UnwrapRefSimple<T>

// reactive 的类型声明 - Creates a reactive copy of the original object.
export declare function reactive<T extends object>(target: T): UnwrapNestedRefs<T>;
...
// UnwrapNestedRefs<T> 类型
export declare type UnwrapNestedRefs<T> = T extends Ref ? T : UnwrapRefSimple<T>;

reactive 方法的定义于 .../@vue/reactivity/dist/reactivity.global.js。此处是编译后的 JS 版本,需要查看 TS 版本的点此

reactive 接受 object 类型的参数 target。若传入对象只读则返回本身,as 断言关键字表示传入的值一定为 Target 类型,ReactiveFlags.IS_READONLY 根据枚举类判断是否为只读的属性;当传递的对象是普通对象,则会执行创建响应式对象函数 createReactiveObject(...)

export function reactive(target: object) {
  // if trying to observe a readonly proxy, return the readonly version.
  if (isReadonly(target)) {
    return target
  }
  return createReactiveObject(
    target, false, mutableHandlers, mutableCollectionHandlers, reactiveMap
  )
}
...
export function isReadonly(value: unknown): boolean {
  return !!(value && (value as Target)[ReactiveFlags.IS_READONLY])
}
reactive.ts 中返回 createReactiveObject() 的函数 描述
reactive 创建深层响应的可读写代理对象
readonly 创建深层响应的只读代理对象
shallowReactive 创建浅层响应的可读写代理对象
shallowReadonly 创建浅层响应的只读代理对象
function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>
) {
  if (!isObject(target)) {
    if (__DEV__) {
      console.warn(`value cannot be made reactive: ${String(target)}`)
    }
    return target
  }
  // target is already a Proxy, return it.
  // exception: calling readonly() on a reactive object
  if (
    target[ReactiveFlags.RAW] &&
    !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
  ) {
    return target
  }
  // target already has corresponding Proxy
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }
  // only specific value types can be observed.
  const targetType = getTargetType(target)
  if (targetType === TargetType.INVALID) {
    return target
  }
  const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  )
  proxyMap.set(target, proxy)
  return proxy
}

createReactiveObject(...) 大体情况下是返回通过 new Proxy(..) 构造函数构建出来的 proxy。

在使用 reactive 传递参数时,可以是对象也可以是原始值,但是后者并不会被包装成响应式数据;返回的响应式数据本质为 Proxy 对象。

返回的响应式副本与原始数据有关联,当原始对象里的数据或响应式对象里的数据发生变化时,彼此都会被相互影响,但是前者数据改变不会触发界面更新。

  • shallowReactive => 只考虑对象类型最外层的响应式

相比 reactive 遍历所有层次的数据生成响应式,shallowReactive 只处理第一层。

开发中适合纵向深,但仅会更改最外层属性的数据对象。

  • ref => 传入原始数据以创建含有响应式属性 value 的包装式对象

响应式对象中的 __v_isRef 属性用于区分当前对象是 ref 或是普通对象,前者在模板解析期间会直接取出 value 属性(模板中省略 fieldName.value)。

所谓响应式丢失,就是对通过 reactive 生成的响应式对象数据使用展开运算符,将代理的响应式对象数据转换为普通对象数据。此时修改对象的属性值,不会触发更新和模板渲染。ref => 不但可用于实现原始值的响应式代理,还可以用于解决响应式的丢失问题。

<template>
  <div>{{ count }}-<button @click="add">clickme add</button></div>
</template>
<script>
import { ref } from "vue";
export default {
  setup() {
    const count = ref(0), add = () => { count.value++; };
    console.log(count) // RefImpl {__v_isShallow: false, dep: undefined, __v_isRef: true, _rawValue: 0, _value: 0}
    return { count, add };
  },
};
</script>

传递的原始数据可以是原始值也可以是引用值,若传递原始值,则指向原始数据的值保存在返回的响应式数据对象的 .value 中;若传递引用值,则返回的响应式数据对象的 .value 属性中具有指向对应的原始数据(引用值|对象)的 Proxy 副本。

<template>
  <div>
    <div>count01 => {{ count01 }}</div>
    <button @click="add01">Click Me</button>
    <div>count02 => {{ count02 }}</div>
    <button @click="add02">Click Me</button>
  </div>
</template>
<script>
import { ref } from "vue";
export default {
  setup() {
    let origin01 = 0, origin02 = { val: 0 }; // 原始数据分别为原始值、引用值
    let count01 = ref(origin01), count02 = ref(origin02);
    console.log("count01", count01); // count01 RefImpl {__v_isShallow: false, dep: undefined, __v_isRef: true, _rawValue: 0, _value: 0}
    console.log("count02", count02); // count02 RefImpl {__v_isShallow: false, dep: undefined, __v_isRef: true, _rawValue: {…}, _value: Proxy}
    function add01() { count01.value++; }
    function add02() { count02.value.val++; }
    return { count01, count02, add01, add02, };
  }
};
</script>

ref 方法的定义于 ../node_modules/vue/dist/vue.global.js。此处是编译后的 JS 版本,需要查看 TS 版本的点此

export function ref(value?: unknown) {
  return createRef(value, false)
}
...
function createRef(rawValue: unknown, shallow: boolean) {
  if (isRef(rawValue)) {
    return rawValue
  }
  return new RefImpl(rawValue, shallow)
}
...
export function isRef(r: any): r is Ref {
  return !!(r && r.__v_isRef === true)
}

通过 ref 生成的响应式对象都是由 RefImpl 类构造的实例,而 RefImpl 实例对象中的 value 属性会据传入原始数据或引用值,分别对应原始数据或 Proxy 对象。

带泛型 T 的 RefImpl 类中定义辅助操作的私有属性,通过 getter|setter 对 value 进行操作拦截。createRef 中传入的第二个参数默认是 false,那么 _rawValue_value 应根据 toRaw 和 toReactive 方法确定。

class RefImpl<T> {
  private _value: T
  private _rawValue: T
  public dep?: Dep = undefined
  public readonly __v_isRef = true
  constructor(value: T, public readonly __v_isShallow: boolean) {
    this._rawValue = __v_isShallow ? value : toRaw(value)
    this._value = __v_isShallow ? value : toReactive(value)
  }
  get value() {
    trackRefValue(this)
    return this._value
  }
  set value(newVal) {
    const useDirectValue = this.__v_isShallow || isShallow(newVal) || isReadonly(newVal)
    newVal = useDirectValue ? newVal : toRaw(newVal)
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal
      this._value = useDirectValue ? newVal : toReactive(newVal)
      triggerRefValue(this, newVal)
    }
  }
}
...
export function triggerRefValue(ref: RefBase<any>, newVal?: any) {
  ref = toRaw(ref)
  if (ref.dep) {
    if (__DEV__) {
      triggerEffects(ref.dep, {
        target: ref,
        type: TriggerOpTypes.SET,
        key: 'value',
        newValue: newVal
      })
    } else {
      triggerEffects(ref.dep)
    }
  }
}

在 Vue2 中使用的 this.$refs.xxx 获取元素和组件在 Vue3 中已被移除。现通过定义 ref 对象,并将其绑定到元素或组件的 ref 属性上即可获得元素或组件。

<template>
  <span ref="msgRef">Get the elements and components through the ref</span>
</template>
<script>
import { ref, onMounted } from "vue";
export default {
  setup(){
    const msgRef = ref();
    onMounted(() => { console.log(msgRef.value); });
    return { msgRef };
  };
}
</script>
  • shallowRef => 传基本数据类型时与 ref 无异,不处理对象类型的响应式

ref 传入对象类型的参数时,会创建 value 属性为 Proxy 实例的 RefImpl 对象。

shallowRef 传入对象类型的参数时,会创建 value 属性为 Object 实例的 RefImpl 对象,Object 实例是没有进行响应式处理的。

shallowRef 适合用于后续功能不会修改对象中属性的对象数据,常会通过生成新对象的方式将其替换。

  • triggerRef => 手动触发和 shallowRef 相关联的副作用

  • toRef => 将响应式对象中的某个字段单独提供给外部使用

为避免在模板中使用复杂表示的对象属性,可能会考虑到在返回时以变量接受响应式对象的具体属性,然而这并不能符合预期的获得响应式数据,而只是一个快照。

let slogan = { msg: "Hello", status: 200 }
let s = new Proxy(slogan, {
  set(target, propName, value){
    Reflect.set(target, propName, value)
  }
})
let snapshot = s.msg // 此处无法通过更改 snapshot 达到修改源对象的目的 -> snapshot = Hello

toRef 为响应式对象上的指定字段新建一个 ref 对象,其 value 属性保持与传入对象的源字段同步。

<template>
  <div>
    <div>{{name}} => {{age}}</div>
    <button @click="btnFunc">Click Me</button>
  </div>
</template>
<script>
import {toRef, reactive} from "vue"; // 引入 toRef
export default {
  setup(){
    let user = reactive({ name:'Ok', age:23 }), name = toRef(user,'name'), age = toRef(user,'age'), btnFunc = () => { name.value = "Okk", age.value = 24,console.log(name) }
    return {name,age,btnFunc}
  }
}
</script>
ObjectRefImpl{__v_isRef: true, _defaultValue: undefined, _key: "name", _object: Proxy {name: 'Okk', age: 24}, value: "Okk"}

返回由 toRef 包裹的数据时看似可用 ref 代替,但后续操作的实际数据不再为此前定义的数据,而是由 ref 产生的新数据,所以不符合预期。

toRef 是引用源数据(利用 Getter 将 value 指向源数据),ref 是复制源数据。

<template>
  <div>{{fooRef}}</div>
</template>
<script>
import { reactive, toRef } from "vue";
export default {
  setup() {
    const state = reactive({foo: 1, bar: 2 }); // 响应式对象 state
    const fooRef = toRef(state, "foo");
    fooRef.value++;
    console.log(state.foo) // 2
    state.foo++;
    console.log(fooRef.value); // 3
    return {state, fooRef}
  },
};
</script>
  • toRefs => 剥离响应式对象,将响应式对象中的每个字段作为响应式数据

将响应式对象中的每个属性都转换为单独的响应式数据,响应式对象转换为普通对象。该函数可以让消费组件在不丢失响应式的情况下对返回的对象进行解构与展开操作,解决了直接对响应式对象解构展开所导致的响应性丢失问题。

<template>
  <div>
    <div>{{name}} => {{age}}</div>
    <button @click="btnFunc">Click Me</button>
  </div>
</template>
<script>
import {toRefs, reactive} from "vue"; // 引入 toRef
export default {
  setup(){
    let user = reactive({ name:'Ok', age:23 }), ordinaryUser = toRefs(user), {name,age} = {...ordinaryUser},btnFunc = () => { name.value = "Okk", age.value = 24 }
    console.log("{...user} equals ordinaryUser??? =>", {...user} == ordinaryUser) // false
    console.log(user, "---", ordinaryUser); // Proxy {name: 'Ok', age: 23} '---' {name: ObjectRefImpl, age: ObjectRefImpl}
    return {name,age,btnFunc}
  }
}
</script>
  • readonly 和 shallowReadonly

页面不更新的情况可能是数据已经改变,但并没有响应到页面,或者是被禁止修改数据。readonly 和 shallowReadonly 返回原始对象的只读代理。

前者返回的对象不论嵌套层级多深都无法更改,后者返回的对象只禁止最外层的数据修改。经两者处理的源对象允许被修改,且修改后会影响只读处理的返回值。

只读处理适用于数据并非在该组件定义,且不可修改的情况。本质上就是对所返回的只读代理对象的 setter 方法进行劫持。

  • toRaw 和 markRaw

toRaw 返回由 reactive()、readonly()、shallowReactive() 或 shallowReadonly() 创建的代理对应的原始对象。

toRaw 将一个由 reactive 生成的响应式对象转换为普通对象。

用于读取响应式对象所对应的普通对象,开发中常见于深层嵌套且没有变更需求的数据。对这个普通对象的所有操作,不会引起页面的更新。

export function toRaw<T>(observed: T): T {
  const raw = observed && (observed as Target)[ReactiveFlags.RAW]
  return raw ? toRaw(raw) : observed
}

markRaw 所标记的对象永远不会成为响应式对象。

适用于渲染具有不可变数据源的大列表时,跳过响应式以提高性能;在响应式对象上追加第三方类库的场景。

为响应式对象追加的属性也会是响应式的,即会引起页面的变化。当追加无需修改的数据时,可先将其进行 markRaw 标记,再进行追加。

  • customRef => 创建自定义的 ref,对其依赖项跟踪和更新触发显示控制

在 get 函数中调用 track 以追踪依赖的改变;在 set 函数中调用 trick 以重新触发模板的解析。

function xxxRef(value){
  return customRef((track, trigger) => {
    return {
      get(){
        track();
        return value;
      },
      set(newValue){
        value = newValue;
        trigger();
      }
    }
  })
}
  • unref => 获取 ref 引用中的 value,或返回参数本身

  • provide & inject => 实现组件的跨级通讯

上游组件通过 provide 函数提供数据,下游组件通过 inject 函数使用数据。

provide 不再是一个对象或返回对象的函数,而是接收注入键与注入值的函数。

inject 不再是一个字符串数组或对象,而是需要接收注入键与可选默认值的函数。

为增加 provide 与 inject 之间的响应性,可对 provide 传入 ref 与 reactive。

Vue3 中使用 inject 的 options API 注入,模板中需手动解包。

<template>
  <inject-test></inject-test>
</template>
<script>
import { provide, ref } from "vue";
import InjectTest from "./InjectTest.vue";
export default {
  components: {
    InjectTest
  },
  setup(){
    const msg = ref("Hello, provide & inject!");
    provide("msg", msg);
    return {
      msg
    };
  }
}
</script>
<template>
  <span>Info: {{msg}}-{{urgency}}</span>
</template>
<script>
import { inject } from "vue";
export default {
  setup(){
    const msg = inject("msg");
    const urgency = inject("urgency", "routine");
    return {
      msg,
      urgency
    };
  }
}
</script>

生命周期

  • 原 beforeDestroy 与 destroyed 变为 beforeUnmount 与 unmounted。

  • setup 函数中要将生命周期加上 on 前缀的小驼峰形式。

  • 同时存在 setup 里的生命周期和选项式生命周期,前者会在后者前触发。

new Vue() => app = Vue.createApp(options); app.mount(el)

计算属性与侦听器

写在 setup 中的计算属性需要传入一个回调函数作为参数,并接收此函数的返回值为计算属性的结果。

<template>
  <div class="hello">{{ nickname }}</div> /*<!--Hello-->*/
</template>
<script>
import { ref, computed } from "vue";
export default {
  name: "HelloWorld",
  setup() {
    const myname = ref("hello");
    const nickname = computed(() => myname.value.substring(0, 1).toUpperCase() + myname.value.substring(1));
    return {
      nickname,
    };
  },
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

watch 侦听函数在引入后可传入三个参数,侦听数据源、传入新旧参数的执行函数和配置对象。

  • 监视 ref 定义的响应式数据和监视多组 ref 定义的响应式数据;
watch(dataSource, (newValue, oldValue) => {...}, {immediate: true})
watch([dataSource01, dataSource02], (newValue, oldValue) => {...}, {immediate: true})
  • 监视 reactive 定义的响应式数据的全部属性时,无法获取 oldValue,且默认开启深度监听,deep 配置也会失效;
watch(dataSource, (newValue, oldValue) => {...}, {deep: false}) // deep 配置失效
  • reactive 响应式数据中的属性不能直接侦听,需使用返回该属性的 getter 函数作为数据源。当侦听属性是对象类型时,不会自动开启深层侦听,需要手动开启。
watch(() => xxx.dataSource, (newValue, oldValue) => {...})
watch([() => xxx.dataSource01, () => xxx.dataSource02], (newValue, oldValue) => {...})

侦测的是结构,不是具体的值,故基本数据类型的 RefImpl 对象不需要通过 .value 指定值作为数据源。

将对象数据类型传入 ref 函数所生成的响应式数据,需要通过 .value 指定才可作为数据源。因 RefImpl 中的 value 属性(Proxy 对象)不再是基本类型,只有在整个被替换时(内存中的地址改变),变化才可以被监视发现。此外开启深度监视也可

与需要显示指定依赖的 watch 不同,watchEffect 会立即执行传入的函数,并在执行过程中追踪依赖,当依赖变更时会重新运行该函数。适合依赖和逻辑强相关的场景。

const count = ref(0)
watchEffect(() => console.log(count.value)) // -> logs 0
setTimeout(() => { count.value++ }, 1000) // -> logs 1

与 watchEffect 相比,watch 允许惰性地执行副作用,即回调仅在侦听源发生更改时调用;更具体地说明应触发侦听器重新运行的状态;能访问被侦听状态的先前值和当前值。watchEffect 在监视回调中使用了什么属性,就默认监听什么属性。watchEffect 一定程度上和 computed 类似,但前者更注重回调函数的函数体,所以不需要写返回值,后者注重回调函数的返回值,所以一定得写。

// !!单一源
// 侦听一个 getter
const state = reactive({ count: 0 })
watch(
  () => state.count,
  (count, prevCount) => {
    /* ... */
  }
)
// 直接侦听一个 ref
const count = ref(0)
watch(count, (count, prevCount) => {
  /* ... */
})

// !!多个源
watch([fooRef, barRef], ([foo, bar], [prevFoo, prevBar]) => {
  /* ... */
})

某些情况下需要停止侦听器,此时应调用 watch 或 watchEffect 返回的函数。

const stopWatchXxx = watchEffect(() => {
  console.log("watch execution...", xxx.value);
})
const changeXxx = () => {
  xxx.value++;
  if(xxx.value > 6) { stopWatch(); }
};

自定义 Hooks

Hooks 本质是函数,把 setup 中使用的 composition API 进行封装,类似 mixin。

import { reactive, onMounted, onBeforeUnmount } from "vue";
export default function (){
  let pointPosition = reactive({ x: 0, y: 0 });
  function savePoint(event){
    pointPosition.x = event.pageX
    pointPosition.y = event.pageY
  }
  onMounted(() => { window.addEventListener('click', savePoint) })
  onBeforeUnmount(() => { window.removeEventListener('click', savePoint) })
  return pointPosition
}
// utils/useCounter.js
import { ref } from "vue";
export default function (){
  let counter = ref(100);
  const increment = () => { counter.value++; console.log(counter.value); };
  const decrement = () => { counter.value--; console.log(counter.value); };
  return { counter, increment, decrement };
}
// xxx.vue
export default {
  setup(){
    return { ...useCounter() }
  }
}
import { ref, watch } from "vue";
export default function (val){
  const title = ref(val);
  watch(title, (newValue) => {
    document.title = newValue;
  }, {
    immediate: true
  })
  return title
}

响应式数据判断

  • isRef => 检查一个值是否为 ref 对象

  • isReactive => 检查一个对象是否是由 reactive 创建的响应式代理

  • isReadonly => 检查一个对象是否是由 readonly 创建的响应式代理

  • isProxy => 检查一个对象是否是由 reactive 或 readonly 方法创建的代理

Vue Router 4.x

  • 创建路由实例的方式改变

从 v3.x 中以 new VueRouter 的方式创建路由实例,到 v4.x 中改用 createRouter

// 创建路由实例并传递 `routes` 配置
const router = VueRouter.createRouter({
  history: VueRouter.createWebHashHistory(), // 使用 hash 模式
  routes, // `routes: routes` 的缩写
})

确保 _use_ 路由实例使整个应用支持路由 => app.use(router)。

  • 路由模式配置的改变

在 v3.x 中的路由模式是通过 mode 属性控制(值为字符串),现通过 import 引入不同函数来指定对应路由模式。mode 属性改为 history。

"history" => createWebHistory()
"hash" => createWebHashHistory()
"abstract" => createMemoryHistory()
  • 4.x Composition API => 路由地址和路由实例需以 hooks 的形式调用取得
  • 捕获路由与 404 Not found 路由 => pathMatch

对于未匹配到的路由,可通过编写动态路由匹配所有页面,并使用指定参数获取匹配内容。

{ path: "/:pathMatch(.*)", component: () => import("../pages/NotFound.vue") }
<span>Not Found: {{ this.$route.params.pathMatch }}</span>

/:pathMatch(.*)/:pathMatch(.*)* 的区别在于结果是否进行解析。

Not Found: ["hello", "vue-router", "not-found"] // /:pathMatch(.*)*
Not Found: hello/vue-router/not-found // /:pathMatch(.*)
  • 动态添加路由 => 开发中常根据用户的不同权限来注册不同的路由

v4.x 中废弃 router.addRoutes,仅存 router.addRoute。

/* 函数签名 */
addRoute([parentName: string,] route: RouteConfig): () => void

添加新路由规则时若有设置 name,会对此前同 name 的路由规则进行覆盖。

const categoryRoute = {
  path: "/category", component: () => import("../pages/Category.vue")
}
router.addRoute(categoryRoute);
const hardwareTestRoute = {
  path: "hardware-test",
  component: () => import("../pages/HardwareTest.vue")
}
router.addRoute("hardware", hardwareTestRoute);
  • 路由导航守卫

vue-router 提供的导航守卫主要用来通过跳转或取消的方式守卫导航

全局前置守卫 beforeEach 在导航触发时回调,接收即将进入和离开的 Route 路由对象 to & from。其返回值 false 表示取消当前导航,不返回或返回 undefined 则进行默认导航,也可以返回字符串类型的路由地址,或者包含路径参数的对象。

第三个可选的参数 next 不推荐使用。在 Vue2 中通过 next 函数决定如何跳转,但是在 Vue3 中应通过返回值控制,且应避免多次的调用 next。

Vuex

组件化开发中,data 定义或在 setup 里使用的数据可看作 State;模板 template 最终会被渲染成 DOM,称之 View;State 的修改联系模块内的行为事件 Actions。

  • 组合式 API

调用 useStore 函数在 setup 里访问 store,与选项式 API 访问 this.$store 等效。

import { toRefs } from "vue";
import { useStore } from "vuex";
const store = useStore();
const { counter } = toRefs(store.state);
function increment(){ store.commit("increment")}
  • 状态映射到组件保持响应式
import { computed, toRefs } from "vue";
import { useStore, mapState } from "vuex";
const store = useStore();
const { name, level } = mapState(["name", "level"]);
// 1.使用 bind 绑定
const responseName = computed(name.bind({$store: store}));
const responseLevel = computed(level.bind({$store: store}));
// 2.自定义封装的 bind 绑定 hook
const { responseName, responseLevel } = useResponseState(["name", "level"]);
import { computed } from "vue";
import { useStore, mapState } from "vuex";
export default function useResponseState(mapper){
  const store = useStore();
  const stateMapperRes = mapState(mapper);
  const resState = {};
  Object.keys(stateMapperRes).forEach(key => {
    resState[key] = computed(stateMapperRes[key].bind({$store: store}))
  })
  return resState
}
// 3.toRefs
const { name: responseName, level: responseLevel } = toRefs(store.state);

内置组件

  • Teleport => 将组件的 HTML 结构移动到指定位置

开发中不想让常规处于屏幕中央的弹窗结构出现在后代组件的内部,因组件树上的任何存在的定位都可能使内部定位样式受到干扰。

异步组件需借助 defineAsyncComponent 函数,import 的调用结果作为返回值。

// 动态|异步引入
import {defineAsyncComponent} from 'vue'
const Xxx = defineAsyncComponent(() => import('./components/Xxx.vue'))
// 静态引入
import Xxx from './component/Xxx.vue'

静态引入的潜在风险是当嵌套最深的组件渲染迟滞时,会拖慢其外层所有组件的渲染。动态引入的问题是当网速较慢时,嵌套的内容渲染可能会出现抖动的情况。抖动可通过无需引入的内置组件 Suspense 来解决,其底层通过插槽实现。Suspense 组件有两个仅接收一个直接子节点的插槽。

<Suspense>
  <template v-slot:default>
    <Xxx> // 待展示组件放入 default 插槽
  </template>
  <template v-slot:fallback>
    <Yyy> // 备用展示内容放入 fallback 插槽
  </template>
</Suspense>

setup 不能是 async 函数(异步组件除外),因其返回值不是对象,而是模板解析不了的 Promise,此时模板获取不到 return 对象中的属性。

当使用 Suspense 与 defineAsyncComponent 时,可以返回异步 Promise 实例。

Vite

在不提供块级作用域时,模块化常用社区规范 commonJS 使用函数作用域 IIFE 进行模拟(ES6 之前的 JavaScript )。

  • 相较 Vue CLI,轻量级脚手架工具 Vite 默认安装的插件更少
  • 考虑到开发过程中依赖增加与额外配置,在实际项目中还是推荐 Vue CLI
  • Vite => 基于缓存的热更新;Vue CLI => 基于 Webpack 的热更新
  • Vite 使用 ES6 的模块化加载,在开发模式中不需要打包构建就能直接运行
  • esbuild 预构建依赖相比于 JavaScript 编写的 Webpack 在速度上更快

预构建依赖 => 开发服务器 DevServer 启动前对将所有代码视为原生 ES 模块(将作为 CommonJS 或 UMD 发布的依赖项转换为 ESM),然后在分析模块的导入时会动态地应用构建过的依赖

  • 搭建 Vite 项目
# 安装 vite 自定义初始化项目
npm init vite@latest | yarn create vite

# 也可以通过附加的命令行选项直接指定项目名称和想要使用的模板
# npm 7+, 需要额外的双横线:
npm init vite@latest my-vue-app -- --template vue
# yarn
yarn create vite my-vue-app --template vue

cd my-project # 切换初始化的项目目录
npm install # 安装设定包内容
npm run dev # 启动项目;package.json中有相应信息

破旧立新

Proxy - 数据劫持优化

Vue2.x 通过 defineProperty 对属性读取和修改进行拦截,即数据劫持。

Object.defineProperty(obj, prop, descriptor)

设定数据属性的 defineProperty 等价于 targetObject[propertyName] = ?

Object.defineProperty(targetObject, propertyName, { value: 'value' })
Object.defineProperty(targetObject, propertyName, {
  enumerable: false,
  configurable: false,
  writable: false,
  value: 'value'
})
  • 数据属性:Configurable、Enumerable、Writable、Value;

  • 访问器属性:Configurable、Enumerable、Set、Get

data 选项定义的属性会被递归遍历的设置 Get、Set 访问器属性描述符。故 Vue2.x 中新增、删除预先不存在的属性或直接使用下标修改数组都无法实现响应式,页面也不会正常更新。

// 操作对象解决方案 => 无法直接赋值增加或者删除对象属性
import Vue from 'vue'
Vue.set(obj,'key','value') | Vue.delete(obj,'key') // way1
this.$set(obj,'key','value') | this.$delete(obj,'key') // way2
// 操作数组解决方案 => 无法直接通过下标赋值修改或者删除对象属性
import Vue from 'vue'
Vue.set(arr,index,'value') | Vue.delete(arr,index) // way1
this.$set(arr,index,'value') | this.$delete(arr,index) // way2
this.arr.splice(0,1,'value') | this.arr.splice(0) // way3

Vue3.x 使用内置的构造函数 Proxy 创建代理,拦截属性的变化,并通过 Reflect 对被代理的对象属性进行操作。

let p = new Proxy(targetObject, {
  get(target, propName) { return Reflect.get(target, propName) },
  set(target, propName, value) { Reflect.set(target, propName, value) },
  deleteProperty(target, propName) { return Reflect.deleteproperty(target, propName) }
})

Vue3.x 使用 Proxy 只会对真正访问到的内部属性进行惰性响应式,而 Vue2.x 对深层嵌套的对象递归遍历处理以实现响应式,无疑会造成较大的性能开销。

编译时的底层源码优化

  • slot 编译优化

Sub 组件仅在被传入动态 slot 的情况下随 Sup 组件的更新而更新。

  • diff 算法优化

静态标记取缔全量比较。将渲染颗粒度从组件级降低到区块级,渲染效率不再与模板大小成正相关,而是与动态节点的数量成正相关。只对比虚拟节点中带有数字枚举类型 patchFlag 值的节点,其他节点形成 block tree 稳定结构区域。

// 见 Vue Template Explorer
<div class="hello">Hello World!</div>
<div class="zs">Hello zs!</div>
<div>{{msg}}</div>
import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    _createElementVNode("div", { class: "hello" }, "Hello World!"),
    _createElementVNode("div", { class: "zs" }, "Hello zs!"),
    _createElementVNode("div", null, _toDisplayString(_ctx.msg), 1 /* TEXT */)
  ], 64 /* STABLE_FRAGMENT */))
}

通过静态提升 hoistStatic,使不参与更新的元素在每次需要渲染时仅做复用,不做新建。

import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
const _hoisted_1 = /*#__PURE__*/_createElementVNode("div", { class: "hello" }, "Hello World!", -1 /* HOISTED */)
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", { class: "zs" }, "Hello zs!", -1 /* HOISTED */)
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    _hoisted_1,
    _hoisted_2,
    _createElementVNode("div", null, _toDisplayString(_ctx.msg), 1 /* TEXT */)
  ], 64 /* STABLE_FRAGMENT */))
}

事件会被视作动态绑定,故每次比较都会追踪其变化。但往往事件绑定的都是相同函数,没有追踪变化的必要。可采取 cacheHandlers 进行缓存操作,等待复用。

// vue
<button @click="clickHandler">click me</button>
// 事件监听缓存之前
const _hoisted_1 = ["onClick"]
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("button", { onClick: _ctx.clickHandler }, "click me", 8 /* PROPS */, _hoisted_1))
}
// 事件监听缓存之后失去静态标记
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("button", {
    onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.clickHandler && _ctx.clickHandler(...args)))
  }, "click me"))
}
  1. hoistStatic 通过 _createStaticVNode 将静态标签转化为字符串;
  2. 服务端渲染是通过 _ssrRenderAttrs 将静态标签直接转化为文本插入;
  3. React 是先将 JSX 转化为虚拟 DOM,再转化为 HTML;
<div class="hello">Hello World!</div>
<div class="zs">Hello zs!</div>
<div>{{msg}}</div>
import { mergeProps as _mergeProps } from "vue"
import { ssrRenderAttrs as _ssrRenderAttrs, ssrInterpolate as _ssrInterpolate } from "vue/server-renderer"
export function ssrRender(_ctx, _push, _parent, _attrs, $props, $setup, $data, $options) {
  const _cssVars = { style: { color: _ctx.color }}
  _push(`<!--[--><div${
    _ssrRenderAttrs(_mergeProps({ class: "hello" }, _cssVars))
  }>Hello World!</div><div${
    _ssrRenderAttrs(_mergeProps({ class: "zs" }, _cssVars))
  }>Hello zs!</div><div${
    _ssrRenderAttrs(_cssVars)
  }>${
    _ssrInterpolate(_ctx.msg)
  }</div><!--]-->`)
}
  • tree-shaking 减少打包体积

在编译阶段标记未被引用的函数或对象,在压缩阶段删除标记的代码以实现按需打包。该优化可有效阻止构建时将引入的模块全部打包。

React's Hooks & Composition API

mixin 与组件之间存在隐式依赖:mixin 中定义的方法可能会去调用其他方法。

高阶组件采取黑盒外层包裹组件,增加了复杂度和理解成本。

Render Props 会导致代码体积过大,嵌套过深的问题。

React Hooks 会在每次组件渲染时顺序执行。不允许在循环内部、条件语句或嵌套函数中调用 Hooks => 底层是基于链表的实现,每一个 hook 的 next 会指向下一个 hook。

组合式 API 只能在 setup 钩子中使用,更改 data 会使相关函数或模板重新计算。

Ref 自动解包

  • 模板中的解包是浅层的解包

常规 ref 在模板中作为顶层 property 被访问时将自动解包,不需要使用 .value。

当 ref 放入普通对象时,在模板中的使用需要 .value。

<template>
  <span>{{ info.msg.value }}</span>
</template>
<script>
import { ref } from "vue";
export default {
  setup(){
    const msg = ref("Hello, Unpack or not!");
    const info = { msg };
    return { msg, info }
  }
}
</script>
  • ref 放入 reactive 的属性中,在模板里使用会自动解包
<template>
  <span>{{ info.msg }}</span>
</template>
<script>
import { ref, reactive } from "vue";
export default {
  setup(){
    const msg = ref("Hello, Unpack or not!");
    const info = reactive({ msg });
    return { msg, info }
  }
}
</script>

Vue CLI - @vue/cli 5.0.8

  • main.js => 程序入口文件

Vue2 => 引入 Vue 函数,以 new 的方式创建 Vue 实例并挂载到 DOM。
Vue3 => 解构 createApp 函数,在其后链式调用方法,并挂载于 DOM。

// vue2
import Vue from 'vue';
import App from './App/vue';
import router from './router';
import store from './store';
Vue.config.productionTip = false;
new Vue({ router, store, render: h => h(App) }).$mount("#app")
// vue3
import { createApp } from 'vue';
import App from './App/vue';
import router from './router';
import store from './store';
createApp(App).use(router).use(store).mount('#app');
  • vuex => new Vuex.Store() 转变为 createStore()

  • 配置文件变化 => 非 Vue3 的变化 => 常作为第三方引入组件的配置

  • @vue/cli3 启动时不会创建 vue.config.js,欲改 Webpack 配置时自行创建

vue-cli(1.x、2.x) 的后续的版本虽已内部高度集成 Webpack,但依然可以通过创建 vue.config.js 去覆盖默认的配置文件。

其他改变

  • 全局 API 的转移 => Vue.xxx 调整到应用实例 app 上
|       2.x 全局 API       |         3.x 实例 API        |
|:------------------------:|:---------------------------:|
|      Vue.config.xxx      |        app.config.xxx       |
| Vue.config.productionTip |            remove           |
|       Vue.component      |        app.component        |
|       Vue.directive      |        app.directive        |
|         Vue.mixin        |          app.mixin          |
|          Vue.use         |           app.use           |
|       Vue.prototype      | app.config.globalproperties |
  • 过渡类名更改
// v2.x
.v-enter, .v-leave-to {opacity: 0;}
.v-leave, .v-enter-to {opacity: 1;}
// v3.x 
.v-enter-from, .v-leave-to {opacity: 0;}
.v-leave-from, .v-enter-to {opacity: 1;}
  • 因兼容性移除 keyCode 作为 v-on 的修饰符;不再支持 config.keyCodes
<!-- Vue 2 Key Code on v-on -->
<input v-on:keyup.13="submit" />
<input v-on:keyup.8="confirmDelete" />
<!-- Vue 3 Key Modifier on v-on -->
<input v-on:keyup.enter="submit" />
<input v-on:keyup.delete="confirmDelete" />
// Vue2 存在 -> 现已移除
Vue.config.keyCodes.defineAliasButton = 13 // 定义按键别名
  • Fragments

因 Vue2.x 不支持 multiple root 组件,所以需要通过将组件都包含在一个 <div> 中修复警告。Vue 3 中支持通过多根节点组件来减少层级。底层逻辑,无需操作。

  • data 选项始终被声明为函数,防止组件复用时数据关联所造成的干扰

  • 移除 v-on.native 修饰符 => native 用于指明原生事件,非自定义事件

// Vue2 默认 click 是自定义事件
<Xxx @click.native="yyy" />

给组件绑定的事件若没有被声明接收,默认为原生事件;若声明接受,则表示为自定义事件。

// Sup 组件绑定事件
<my-xxx v-on:close="handleComponentEvent" v-on:click="handleNativeEvent" />
// Sub 组件声明自定义事件
<script>
  export default {
    emits: ["close"] // 声明则为自定义事件 -> 不声明为原生事件
  }
</script>

Bugs 解决

  • Uncaught TypeError: app.mount(...).use is not a function

main.js 中实例的使用顺序是,先调用 createApp() 创建应用程序实例;再使用 app.use() 安装插件;最后将应用实例挂载于容器元素 app.mount()

const app = createApp(App)
// 重点注意链式调用顺序
app.use(store).use(router).use(ElementPlus).mount('#app');
  • Error: Cannot find module 'unplugin-vue-components/resolvers'

Vue3 项目下按需引入 Vant 时出现异常,模块未找到在 node_modules 中找到。

删除 Vant 依赖后重新下载无法解决。

unplugin-vue-components/resolvers 指定库在 22-07-10 加入 npm。

# 解决方式
yarn add unplugin-vue-components
  • [Vue warn]: Component <Anonymous>: setup function returned a promise, but no <Suspense> boundary was found in the parent component tree. A component with async setup() must be nested in a <Suspense> in order to be rendered. at Xxx.

问题出现 => 在 async setup 的组件中获取数据,运行项目时页面空白。

解决方式 => 需要在父节点中加上 Suspense 组件。

  • "File '?.vue.ts' is not a module" | 文件 "?.vue.ts" 不是模块。ts(2306)

异常场景描述:?.vue 组件中存在空的 <script lang="ts"> 标签。

解决方式:无逻辑组件可去除 ts 的申明;有逻辑组件应该完成一定初始化步骤。

<script lang="ts" setup>
import { ref } from "vue";
let fixVueNotModuleError = ref("okk");
console.log(fixVueNotModuleError.value);
</script>
  • Component name "" should always be multi-word.

异常场景描述:.vue 文件中 name 属性设置后飘红。

解决方式:.eslintrc.js 中对 rules 数组新增一项检测规则。

"vue/multi-word-component-names": "off",
  • Vue+TS 页面刷新后路由匹配 Bug

守卫是异步解析执行,此时导航在所有守卫 resolve 完之前一直处于等待中。

因全局前置守卫 beforeEach 的触发时机早于页面的跳转,故守卫中可通过 torouter.getRoutes 的打印来获取路由的信息。

在路由信息中,路径的映射没有问题,但跳转对象的 name 指向了 not-found。

由于页面刷新会重新执行 main.ts,那么应考虑执行顺序的问题:

  1. 注册路由执行的 use 会调用 router 中 install 方法来获取当前 path
  2. 获取到的 path 会与 router.routes 进行一轮匹配
  3. 若没有注册动态路由,那么会匹配 not-found

解决:动态路由的注册方法应该在路由的注册之前:调换 main.ts 中的执行顺序。

  • Vue+TS 配置全局属性后出现类型不存在的问题:Vue 官方SOF 建议

  • [Vue warn]: Property "xxx" was accessed during render but is not defined on instance. at <ComponentXXX> => 在 setup 定义的数据并未返回

  • Vue3 中不支持 Vue2 的 .sync 语法糖 => 点此

结束

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议,转载请注明出处!

]]>
<![CDATA[MongoDB]]> 文档数据库 MongoDB => 27017]]>https://zairesinatra.github.io//mongodb/611e8b4a0de60f0cb667f229Thu, 20 May 2021 14:52:00 GMT

初识

MongoDB@3.2 后存在较大改动,使用时推荐官方中文文档

安装 MongoDB 5.0 社区版(先决条件:Xcode 命令行工具、Homebrew):

  • 下载 MongoDB 和数据库工具并安装
# tap 命令允许 Homebrew 进入另一个公式存储库. 完成此操作则扩展可安装软件的选项
brew tap mongodb/brew
# 安装
brew install mongodb-community@5.0

安装会在以下指定位置创建文件和目录,具体取决于 Apple 硬件:

英特尔处理器 M1处理器
配置文件 /usr/local/etc/mongod.conf /opt/homebrew/etc/mongod.conf
log directory /usr/local/var/log/mongodb /opt/homebrew/var/log/mongodb
data directory /usr/local/var/mongodb /opt/homebrew/var/mongodb
  • 基本操作
# 将 MongoDB(即mongod进程)作为 macOS 服务运行|停止
brew services start|stop mongodb-community@5.0
# 验证是否正在运行
brew services list
# 连接和使用 MongoDB
mongosh
# 展示数据库
show databases; # 或者 show dbs;
# 显示当前数据库
db
# 查看版本信息
db.version();
# 选中数据库
use $yourdatabase; # 如果没有会隐式创建
# 查看表(集合)
show tables; # 因为并非多行多列,叫表不合适
show collections; ✔️
# 创建集合
db.createCollection("necessities"); # { ok: 1 }
# 删除集合
db.collectionName.drop(); # true
# 删除数据库
db.dropDatabase(); # 删除当前选用的数据库
# 插入 JSON 数据
db.necessities.insert({"name":"Dryer","origin":"San Antonio"}); # ❌ DeprecationWarning: Collection.insert() is deprecated. Use insertOne, insertMany, or bulkWrite. 
# 3.2 版本之后新增了 db.collection.insertOne() 和 db.collection.insertMany()
db.necessities.insertOne({"name":"Dryer","origin":"San Antonio"}); # ✔️
db.necessities.insertMany([{"_id":"612e26f5d5f808a806f499e6","name":"dryer","origin":"San Antonio"},{"_id":"612e27b6d5f808a806f499e8","name":"charger","origin":"San Diego"},{"_id":"612e2e03d5f808a806f499e9","name":"conditioner","origin":"Seattle"},{"_id":"612e3737d5f808a806f499ea","name":"waitforremove","origin":"zsxzy"}]);

MongoDB

# 查询集合数据 —— $currentDB.currentCollection.find(query, projection) 
# query(可选):使用查询操作符指定查询条件;projection(可选):使用投影操作符指定返回的键
db.necessities.findOne()|find()|find().count(); # 查看一个文档|最多20个匹配的文档|查看集合中的文档数量
# 移除集合中的数据
db.necessities.deleteOne({"name":"waitforremove"});
# 更新集合数据
# 如果第二个参数未设置 $set 则会报错 TypeError: Update document requires atomic operators
db.necessities.updateOne({"_id":"612e3737d5f808a806f499ea","name":"waitforremove","origin":"zsxzy"},{$set:{"name":"toytoytoy"}});

查询将会返回一个数据库游标,游标只会在需要时才将需要的文档批量返回;游标 (cursor) 不是查询结果,而是查询的返回资源或者接口。

通常文档只会部分更新。可以使用原子性的更新修改器(update modifier),指定对文档中的某些字段进行更新。更新修改器是种特殊的键,用来指定复杂的更新操作,比如修改、增加或者删除键,还可能是操作数组或者内嵌文档。

假设要在一个集合中放置网站的分析数据,只要有人访问页面,就增加计数器。可以使用更新修改器原子性地完成这个增加。每个 URL 及对应的访问次数都以如下方式存储在文档中:

{
    "_id" : ObjectId("4 字节的时间戳 5 字节的随机值 3 字节递增计数器"),
    "url" : "www.zstheyi.com",
    "pageviews" : 422
}

每次有人访问页面,就通过URL找到该页面,并用 $inc 修改器增加 pageviews 的值。使用修改器时,_id 的值不能改变。

$set 用来指定一个字段的值。如果这个字段不存在,则创建它。常用于更新或者增加文档。删除可以用 $unset 完全删除键。在数组修改器中,$push 会向已有的数组末尾加入元素,要是没有就创建一个新的数组,也可以与 $each 组合一次添加多个值。为了保证数组内元素不重复,$addToSet 与 $ne 可以实现集合数据。$pop 这个修改器根据传入的 key 从数组任何一端删除元素。$pull 会将所有匹配的文档删除,而不是只删除一个。

# 条件操作符 —— $gt (>), $gte (>=), $in (包含), $lt (<), $lte (<=), $ne (!=), $nin(不包含)、$and (&&), $nor(不符合), $not(不包含), $or (||)
db.necessities.find({"name":"dryer","origin":"San Diego"}).pretty(); 
db.necessities.find({$or:[{"name":"dryer"},{"origin":"San Diego"}]}).pretty(); # OR 条件
db.necessities.find({"price":{$gt:"1"}})|find({"_id":{$gt:"1"}})

# $type键类型操作符
db.necessities.find({"name":{$type:"string"}}) # 筛选出指定类型键的数据

# Limit 与 Skip 方法
db.necessities.find().limit(2) # 显示两条文档
db.necessities.find().skip(4) # 跳过指定数量的数据

# 排序操作 —— db.COLLECTION_NAME.find().sort({KEY:1})
db.necessities.find().sort({"price":"1"}) # 1为由小到大,-1为由大到小

# 索引操作
db.necessities.createIndex({"price":1}) # 创建索引
db.necessities.getIndexes() # 查看索引
db.necessities.totalIndexSize() # 查看集合索引大小
db.necessities.dropIndexes() # 删除集合所有索引
db.necessities.dropIndex("price") # 删除集合指定索引

# 聚合(aggregate) —— 主要用于处理数据(诸如统计平均值,求和等),并返回计算后的数据结果
db.necessities.aggregate([{$group:{_id:"$origin",total:{$sum:"$price"}}}]) # 通过字段 origin 字段对数据进行分组,并计算 price 字段相同值的总和

插入文档数据不能使用 db.$yourcollection.insert() 进行操作,而是需要采取 insertOne() 或者 insertMany()。更新文档数据时,使用 replaceOne(),只能替换整个文档;而 updateOne() 则允许更新字段,同时还可以访问 update operators 对文档进行可靠更新。移除集合中的数据不再使用 remove(),而改用 deleteOne, deleteMany, findOneAndDelete 或 bulkWrite。

  • 在 Navicat Premium 连接 MongoDB

在 connection 选择对应数据库后,按照默认规则连接即可,连接的默认端口为 27017。在工具栏的 View 选择 Show Hidden Items 可展示隐藏初始的数据库:admin、config、local。

Mongoose

Mongoose 是一个 NodeJS 专门用于操作 mongodb 数据库的模块,也是一个在异步环境中工作,支持 promise 和异步回调的对象文档模型库。Mongoose 可以为文档创建一个模式结构 (Schema|约束),并对模型中的对象/文档进行验证,数据可以通过类型转换为对象模型,以及可以使用中间件来应用业务逻辑挂钩,比较原生的 MongoDB 驱动更容易(进一步封装)。

mongoose对象 作用描述
Schema 模式对象定义约束了数据库文档结构
Model Model 对象作为所有文档的表示,相当于数据库的集合 collection
Document 表示集合中的具体文档,相当于集合中的一个具体文档
# 下载 mongoose 连接数据库
npm init -y
npm i mongoose --save
// 基本连接
// 项目引入mongoose
var mongoose = require('mongoose');
// 连接 mongodb —— 除非项目停止,服务器关闭,否则连接一般不会断开
mongoose.connect('mongodb://localhost:27017/数据库名');
// 监听连接状态 —— mongoose 有一个属性叫做 connection,该对象表示数据库连接
var db = mongoose.connection;
db.on('error', console.error.bind(console, 'connection error:'));
db.once('open', function() { // 数据库连接的事件
  // you're connected!
});
db.once('close', function() { // 数据库断开的事件
  // bye!
});
// 断开数据库连接(一般不需要)
mongoose.disconnect()
// 使用 Schema
var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost:27017/mongoose_sgg');
var db = mongoose.connection;
db.on('error', console.error.bind(console, 'connection error:'));
db.once('open', function() {
  console.log("you're connected!");
});
db.once('close', function() {
  console.log("bye!");
});
// Schema是构造函数
var Schema = mongoose.Schema;
var stuSchema = new Schema({ // 创建对数据库的约束
    name: String,
    age: Number,
    gender:{
        type: String, // Boolean限制太多,开发不常用
        default: "female"
    },
    address: String
})
// 通过 Schema 创建 Model 数据库中的集合
// mongoose.model(modelName, schema);
// 和数据库的modelName的集合映射,通过stuSchema进行约束
var StuModel = mongoose.model("student", stuSchema); // mongoose会自动将集合名变为复数
// 数据库插入文档
StuModel.create({
    name: "bjj",
    age: 17,
    address: "whitebonehole"
}, function(err){
    if(!err){
        console.log("插入成功");
    }
})
var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost:27017/mongoose_sgg');
var db = mongoose.connection;
db.on('error', console.error.bind(console, 'connection error:'));
db.once('open', function() {
  console.log("you're connected!");
});
db.once('close', function() {
  console.log("bye!");
});
var Schema = mongoose.Schema;
var stuSchema = new Schema({
    name: String,
    age: Number,
    gender:{
        type: String,
        default: "female"
    },
    address: String
})
var StuModel = mongoose.model("student", stuSchema);
// 有了文档对象就可以对文档进行增删改差
// Model.create(docs,[callback])
// StuModel.create([
//     {
//         name: "zbj",
//         age: 28,
//         gender: "male",
//         address: "gaolaozhuang"
//     },
//     {
//         name: "ts",
//         age: 15,
//         gender: "male",
//         address: "datang"
//     }
// ], function(err, jellybean, snickers){ // 返回成功状态以及插入结果
//     if(!err){
//         console.log("插入成功");
//     }
// })
// projection-投影(需要获取的字段)、options-查询选项(skip、limit)、callback-查询结果通过回调函数返回
// Model.find(conditions, [projection], [options], [callback]); —— 查询符合条件文档,总会返回数组
// Model.findById(conditions, [projection], [options], [callback]);
// Model.findOne(conditions, [projection], [options], [callback]);
StuModel.find({name:"ts"}, {name:1, _id:0, address:1}, function(err,docs){ // 显示name不显示_id => 可以直接写字符串,不需要的前面加上-
    if(!err){
        console.log(docs); // 返回的是数组 [ { name: 'ts', address: 'datang' } ]
        console.log(docs[0].name); // ts
    }
})
StuModel.find({}, "name -_id", {skip:1}, function(err,docs){ // 显示name不显示_id => 可以直接写字符串,不需要的前面加上-
    if(!err){
        console.log(docs); // [ { name: 'bjj' }, { name: 'zbj' }, { name: 'ts' } ]
    }
})
StuModel.findById("613397ae66319ccd3dbb0c05", function(err,doc){ // 显示name不显示_id => 可以直接写字符串,不需要的前面加上-
    if(!err){
        console.log(doc); // 指定_id的对象
    }
})
StuModel.findOne({}, "name -_id", {skip:1}, function(err,doc){ // 显示name不显示_id => 可以直接写字符串,不需要的前面加上-
    if(!err){
        console.log(doc); // { name: 'bjj' }
        // Document对象是Model的实例
        console.log(doc instanceof StuModel) // true
    }
})
// doc为修改后的对象
// Model.update(conditions, doc, [options], [callback]);
// Model.updateMany(conditions, doc, [options], [callback]);
// Model.updateOne(conditions, doc, [options], [callback]);
// 修改ts年龄为20
StuModel.updateOne({name: 'ts'}, {$set:{age:20}}, function(err){
    if(!err) {
        console.log("修改成功");
    }
})
// Model.remove(conditions, [callback]); 一个已弃用的函数,已被 deleteOne() 删除单个文档和 deleteMany() 删除多个文档取代
// Model.deleteOne(conditions, [callback]); 用于删除单个文档
// Model.deleteMany(conditions, [callback]); 在删除后返回已删除的文档以防在删除操作后需要其内容
StuModel.remove({name: 'zbj'},function(err){
    if(!err) {
        console.log("删除成功");
    }
})
// Module.count(condition, [callback])
StuModel.count({}, function(err, count){
    if(!err){
        console.log(count); // 效果比find得出数组后length好,不必查找每个文档
    }
})
var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost:27017/mongoose_sgg');
var db = mongoose.connection;
db.on('error', console.error.bind(console, 'connection error:'));
db.once('open', function() {
  console.log("you're connected!");
});
db.once('close', function() {
  console.log("bye!");
});
var Schema = mongoose.Schema;
var stuSchema = new Schema({
    name: String,
    age: Number,
    gender:{
        type: String,
        default: "female"
    },
    address: String
})
var StuModel = mongoose.model("student", stuSchema);
// Document和集合中文档一一对应,Document是Model实例
// 通过Model查询结果都是Document
// 创建一个document
// var stu = new StuModel({
//     name:"zxxz",
//     age:19,
//     gender: "female",
//     address: "tiangong"
// });
// stu.save(function(err){
//     if(!err){
//         console.log("保存成功"); // Saves this document.
//     }
// })
StuModel.findOne({},function(err, doc){
    // 方式一
    // if(!err){
    //     // update(update,[option],[callback])
    //     doc.updateOne({$set:{age:20}},function(err){
    //         if(!err){
    //             console.log("修改成功");
    //         }
    //     })
    // }
    // 方式二
    // doc.age = 19;
    // doc.save();
    // doc.remove(function(err) {
    //     if(!err){
    //         console.log("大师兄再见");
    //     }
    // })
    // get(name) 获取文档中指定属性值
    // console.log(doc.get("name")); // 等价于 console.log(doc.name);
    // set(name, value) 设置文档指定属性值
    // doc.set("name","nicebjj") // 数据库不变,因为没有调用save 等价于 doc.name="hahaha"
    // console.log(doc);
    // toJSON() 转换为一个JSON对象
    // toObject() 文档对象转化为一个普通的js对象 —— 部分方法使用不了
})

MongoDB NodeJS Driver

Tips

MongoDB 自增 ID

MongoDB 没有像 SQL 一样有自动增长的属性,MongoDB 的 _id 是系统自动生成的 12 字节唯一标识。为实现 ObjectId 自动增长,需要辅助函数进行实现。

  • 使用指定集合 => 以 channels 为例
db.createCollection("channels")
  • 向集合中插入以下文档,使用 cid 作为 key
db.counters.insertOne({_id:"cid","sequence_value":0})
  • 创建函数 getNextSequenceValue 作为序列名的输入
function getNextSequenceValue( sequenceName ) {
  var sequenceDocument = db.counters.findAndModify(
    {
      query:{_id: sequenceName },
      update: {$inc:{sequence_value:1}},
      new:true
    });
  return sequenceDocument.sequence_value;
}
  • 调用 getNextSequenceValue 函数设置文档 _id 自动为返回的序列值
db.channels.insertOne({"_id":getNextSequenceValue("cid"),"channels_name":"推荐"})
{ acknowledged: true, insertedId: 1 }
db.channels.insertOne({"_id":getNextSequenceValue("cid"),"channels_name":"c++"})
{ acknowledged: true, insertedId: 2 }

结束

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处 zairesinatra's blog

]]>
<![CDATA[VSCode & Eclipse & IntelliJ]]>https://zairesinatra.github.io//vsc-eclipse-itj/60f202ed0c77773fa52cbc2cThu, 22 Apr 2021 13:36:00 GMT

VSCode

ESLint 配置

VSCode & Eclipse & IntelliJ

通常 Webpack 开发服务器会配合 ESLint 检查代码,可通过 Visual Studio Code 的 ESLint 插件配置完成代码的自动检查与修改。因 ESLint 需使用 .eslintrc.js 配置文件,所以要将脚手架工程作为 Visual Studio Code 的根目录。

Visual Studio Code 拓展中选择 ESLint 插件右下角的齿轮 => 点击拓展设置后选择工作区 => 点击右上角的打开设置 => 在 settings.json 中追加代码。

"eslint.run": "onType",
"editor.codeActionsOnSave": {
  "source.fixAll.eslint": true
}

Vue3 代码模板

  • Code => 首选项 => 配置用户代码片段 => 新建全局代片段文件 => 命名

  • 在新建的 .vue 结尾文件输入 vue3 后,按键盘的 TAB 即可自动生成模板

{
  "Print to console": {
    "prefix": "vue3ts",
    "body": [
      "<template>",
      "</template>",
      "",
      "<script setup lang='ts'>",
      "</script>",
	  "",
      "<style scoped lang=''>",
      "</style>"
    ],
    "description": "Log output to console"
  }
}
{
  "Print to console": {
    "prefix": "vue3e",
    "body": [
      "<template>",
      "  <div></div>",
      "</template>",
      "",
      "<script>",
      "import { reactive, toRefs, onBeforeMount, onMounted } from 'vue'",
      "export default {",
      "  name: '',",
      "  setup() {",
      "    console.log('创建组件 => setup')",
      "    const data = reactive({})",
      "    onBeforeMount(() => {",
      "      console.log('组件挂载页面之前执行 => onBeforeMount')",
      "    })",
      "    onMounted(() => {",
      "      console.log('组件挂载页面之后执行 => onMounted')",
      "    })",
      "    return {",
      "      ...toRefs(data),",
      "    }",
      "  },",
      "}",
      "",
      "</script>",
      "",
      "<style scoped lang='less'>",
      "</style>"
    ],
    "description": "Log output to console"
  }
}

快捷指令

  • 整段代码移动 => alt + ⬆️ or ⬇️
  • 自动换行 => cmd + shift + p 输入 settings,在 defaultSettings.json 中将 editor:wordWrap 的 off 改成 on。

Enhanced development

  • 路径别名 @ 替代 src 相对路径 => jsconfig.json 项目生效
{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@/*": ["src/*"],
    }
  }
}
  • .eslintignore => 忽略被仓库管理的目录 -> 忽略风格校验
/dist
/src/vendor

Eclipse

安装 Eclipse

  • 确认 Mac 上是否已安装 java 运行环境
$ java -version
# java version "11.0.11" 2021-04-20 LTS
# Java(TM) SE Runtime Environment 18.9 (build 11.0.11+9-LTS-194)
# Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.11+9-LTS-194, mixed mode)
  • 安装合适的 Eclipse 版本
$ brew install --cask eclipse-java
# 或者去官网 https://www.eclipse.org/downloads/packages/
  • 安装完成后在先前指定路径将 Eclipse 添加到应用程序
# 默认路径
/Users/${zsxzy}/eclipse

配置 Eclipse

  • Preferences => Java => installed JREs 配置 JDK :add 增加 Standard VM 的 JRE Types,并指定 JDK 安装的 Home 文件夹路径。这里选择 Standard VM 是因为 Oracle(以前是 Sun)实现的 JRE Type 向后兼容性较好。参考本人 add JRE 填写后 Finish。
JRE HOME: /Library/Java/JavaVirtualMachines/jdk-11.0.11.jdk/Contents/Home
JRE NAME: HOME
  • 为防止工作区文件乱码,在 Preference => General => workspace 勾选 Defalt(UTF-8)选项并 Apply 。

Eclipse 设置语言

  • 中文

Help => Install New SoftWare 中点击 add 添加从此链接适配版本的中文插件包。这里注意如果在选择 Babel Language Packs in Chinese(Simplified) 全部安装,在本人 X86 架构的 mac 出现 Eclipse 损坏的情况(MacM1 未出现此损毁)。解决方案是不选择 for rt.rap 的项目包。

VSCode & Eclipse & IntelliJ

  • 英文

在应用程序中找到 Eclipse,选择 Show Package Contents,使用文本编辑器打开在 Contents 文件夹中的 Info.plist。在在 Eclipsekey 末尾添加命令行选项。

<string>-nl</string><string>en</string>

VSCode & Eclipse & IntelliJ

Git Version Control

  • Help => Install New SoftWare 中点击 add 添加从此链接适配版本的 EGit 插件包,载入后全选组件并进行安装。

  • 确认 Preference => version control => Git => config => User Settings 添加(若安装过 Git 并链接过 Github 则会存在已自动载入完毕)邮箱与用户名。

  • 在 Team => Share Project 建议自定义 new repo 去实现 Repository 存放路径。

/User/zsxzy/git/repository/(.git)
# .git 会自动添加,无需填写
  • commit => Remote => Push 链接至 Github 并选择 Source ref 别称项为 refs/head/master 完成 Add Spec。

Java 自动补全

Preference => Java => Editor => Content Assist => java automatic activation trigger 中补全单词触发。

abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.

JavaEE perspective

Help -> install new software 搜索符合当前 eclipse 版本号的 item work with。找到 Web, XML, Java EE and OSGI Enterprise Development(可以具体到 Eclipse IDE for Java EE Developers)。

2021-06 - http://download.eclipse.org/releases/2021-06/202106161001

工作区配置

  • Access Commands and other items

navigator、workspace、outline、console

  • New

window -> perspective -> customize perspective -> menu visibility -> file 配置 new 相关。

Java Project、Package、Class、Interface、Enum、Annotation、JUnit Test Case、Folder、File、HTML File、Static Web Project、JSP File、Dynamic Web Project、Servlet、Filter、Listener、XML File

eclipse-jee vs eclipse-java

在 Homebrew 上存在三种类型的 eclipse 版本,cpp、jee、java。其中 Eclipse for Java 是带有 GUI 和 Swings 库的基本 IDE。但缺少用于处理数据库和 Web 开发的部分插件。适用于 Java EE 的 Eclipse IDE(EE 代表企业版)预装了所有这些插件,便于制作完整的软件,也是最理想的选择。理论上可以将所有组件从 Eclipse Java EE 安装到 Eclipse for Java。但是必须下载并解压缩 Eclipse for Java Developers 并安装名为 web tools platform 的插件。而 cpp 版本则是安装了 C/C++ 在此版本的 eclipse 上。

Bugs fixed

  • "You do not have permission to open the application Eclipse JEE.app.Contact your computer or network administrator for assistance."
# Problem Report for eclipse
Crashed Thread:        Unknown

Exception Type:        EXC_CRASH (Code Signature Invalid)
Exception Codes:       0x0000000000000000, 0x0000000000000000
Exception Note:        EXC_CORPSE_NOTIFY

程序运行 crashed 是由于 Code Signature Invalid(签名问题)。虽然 Mac 启用的安全机制默认只信任 Mac App Store 以及拥有开发者 ID 签名的软件,同时阻止没有开发者签名的软件。但是这不是我第一次打开此软件,而是进行系统更新操作后的副作用。在尝试重新给软件进行签名后问题解决。类似问题

$ sudo codesign --force --deep --sign - /Applications/Eclipse\ JEE.app
Password:
/Applications/Eclipse JEE.app: replacing existing signature
  • "The word 'localhost' is not correctly spelled." 提示处理

Preference 输入 spell,将复选框 "Enable spell checking" 去掉即可。

junit 报错 => java.lang.Exception: No tests found matching [{ExactMatcher:fDisplayName=testQuery1]

考虑三个方面,是不是没写 @Test;是否是 public、参数、返回值、修饰符的错误;是不是 spring 包与 junit 的包的兼容问题;是否日志已添加。

  • Mac m1 电脑 idea 卡顿的问题解决

macm1 IntelliJ 应注意兼容的版本,如不是 aarch64,而是 x86,那就是版本错了。

IntelliJ IDEA 2022.1.1 (Ultimate Edition)
Build #...
Licensed to ...
Runtime version: 11.0.14.1+1-b2043.45 aarch64
...

结束

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!

]]>
<![CDATA[Webpack]]>https://zairesinatra.github.io//webpack/60e1e88bdcff0833706c15f9Mon, 08 Mar 2021 13:55:00 GMT

Getting Started

Intro to Webpack

Webpack

Webpack takes modules with dependencies and generates static assets representing those modules.

webpack-cli 是一个与 webpack 相关的命令行工具,用于在终端中执行指定命令,解析命令行参数,并把这些参数传递给 webpack。

注意,webpack 本身并没有内置命令行工具,如果没有安装 webpack-cli,直接在终端里执行 webpack 命令时会报错。

$ npx webpack --entry ./src/main.js --output-path ./build --mode=development

npx 可理解为使用本地的模块,实际上是运行当前项目中 node_modules/.bin 下的可执行文件。

package.json 文件的 scripts 字段不需要在命令的前面加上 npx。与 webpack.config.js 一样,package.json 中的 scripts 字段可以直接使用本地安装的模块或全局安装的命令。

{
  "scripts": {
    "build": "webpack --entry ./src/main.js --output-path ./build --mode=development"
  }
}

npm run 会在当前目录下新建一个 Shell,并将当前目录下的 node_modules/.bin 加入 PATH 变量,使得在执行脚本命令时可以直接引用该目录下的可执行文件。在执行结束以后,路径变量会恢复原样,以保证环境变量的稳定性。这就是 npm run 能够执行项目本地安装的 CLI 工具的原因。

除上述方法外,项目中更多见的是使用 webpack.config.js 之流的配置文件进行打包的相关设置。

const { resolve } = require("path");
module.exports = { 
  entry: "./src/main.js", // 入口文件可以是相对路径
  output: {
  	filename: "bundle.js",
  	path: resolve(__dirname, "./build") // 输出文件路径必须是绝对路径
  },
  mode: "development"
};
$ npx webpack --config webpack.config.js
"scripts": {
  "build": "webpack --config webpack.config.js"
}

webpack 通常使用 CommonJS 模块化规范来读取配置文件,即配置文件中通过 module.exports 导出一个对象,对象中包含了各种打包配置信息。注意,配置文件须放在项目根目录下。

通过读取项目里的配置文件,解析代码中模块引用关系,可将这些模块打包成一个或多个 JS bundle 文件。这些 bundle 文件包含了应用中的所有代码、样式、图片等资源。

通过将这些资源打包成 bundle,可以减少浏览器加载资源的请求次数,提高页面性能和加载速度:

  • 在 HTTP/1.1 协议下,打包文件可以减少客户端发起请求的次数,降低 TCP 连接的占用
  • 在 HTTP/2 协议下,代码分割可以将应用代码拆分成多个小块,进一步提升页面加载速度

HTTP/1.1 协议规定,在客户端发起请求时会建立一个 TCP 连接,在该连接上进行请求和响应。在此期间,不管请求的文件大小是多少,都会占用该连接,直到响应完成才会释放连接。

HTTP/2 协议支持多路复用,即一个 TCP 连接可以同时发送多个请求和响应。因此,使用 HTTP/2 协议时,每个文件的请求和响应都可以独立完成,不会相互影响。同时,HTTP/2 还支持服务器推送,即在客户端请求一个资源时,服务器可以主动推送与该资源相关的其他资源。

在打包时默认会将所有直接或间接被引入到入口文件中的模块都打包,包括那些被引用的但实际上并未被使用的模块。摇树优化可以通过静态代码分析,识别出哪些模块中的代码是无用的,并将其从打包结果中移除,从而减小打包文件的体积。

总的来说,Webpack 是一种静态打包工具,通过在本地将所有代码和资源打包到一个或多个 bundle 之中,并且可以在构建过程中对代码进行一系列的优化,如代码压缩、摇树优化、代码分割等,最终生成优化后的打包文件。Webpack 缺点是构建速度较慢,尤其是在大型项目中,构建时间的可能会很长。

Vite is built on top of Snowpack, a lightweight alternative to Webpack. Vite leverages the native ES module support in modern browsers, employs an on-demand compilation packaging approach, and dynamically compiles code at runtime without the need to pre-bundle all content. This enables Vite to start up and rebuild applications more quickly.

应用程序启动和重新加载过程中常见的名词解释:

  • 热重载或热更新:程序运行时,无需停止或重启应用即可动态更新代码或资源文件
  • 冷启动:在应用的首次启动时,需要重新加载所有必要的资源和配置文件
  • 热启动:在应用已经启动并运行时,再次启动该应用程序(应用的许多资源已在内存中加载)
  • 温启动:在应用已经启动并运行时,重新加载已经被关闭或过期的组件

Basic Loaders

Loaders 用于对模块的源代码进行转换,将一种形式的源代码转换为另一种形式的源代码,以此满足特定的需求。

# 因缺乏相应 loader 而导致的解析失败
Module parse failed: Unexpected token. You may need an appropriate loader to handle this file type.

配置文件中 module.rules 字段对应的值是数组 [Rule]。数组中存放一个或多个 Rule 对象,Rule 对象中具有 testuse 属性。

  • test 字段用于对资源进行匹配,通常设置成正则表达式
  • use 字段值是一个 [UseEntry] 数组,其中 UseEntry 对象又具有以下属性
    • loader 必选属性,对应的值是一个字符串
    • options 可选属性,值是字符串或者对象,通常会传入到 loader 中

基础样式资源的打包通常需要 css-loaderstyle-loader

  • css-loader 处理 @importurl(),将 CSS 文件解析成样式字符串的 CJS 模块加载至 JS
  • style-loader 创建 style 标签并将 JS 里的样式添加到 head 元素(插入 DOM 树)
$ npm i css-loader style-loader less-loader less -D

通常 use 属性的值是一个字符串数组,这是使用了 loader 属性的简写方式。也有将 use 属性省略掉的时候,直接写上 loader 属性,但这只适用于一个 loader 的情况。

module: { // 不同文件必须配置不同 loader 处理
  rules: [
    {
      test: /\.css$/,
      use: [ 'style-loader', 'css-loader' ] // 语法糖
    },
    { 
      test: /\.less$/, 
      use: [ 'style-loader', 'css-loader', 'less-loader' ] // less-loader 将 less 文件编译成 css 
    } 
  ] 
}

在 Concepts 下 Loaders 中除 Configuration 外,还有一种 Inline 内联的方式使用 loaders。多个 loader 的执行顺序是从后往前的。

browserslist 是一款用于配置项目中目标浏览器的工具,可以在不同的工具之间共享信息。

browserslist is used in: Autoprefixer, Babel, postcss-preset-env, eslint-plugin-compat, stylelint-no-unsupported-browser-features, postcss-normalize, obsolete-webpack-plugin...

通过 browserslist 指定一系列的浏览器及其版本,可以通知其他工具应该如何生成代码,以适配指定的目标浏览器(自动添加 CSS 前缀、使用相应的 Polyfill、转换 ES6+ 语法等)。

$ npx browserslist ">1%, last 2 version, not dead"

browserslist 的配置可以写在项目根目录下的 .browserslistrc 中,也可以写在 package.json 文件中的 browserslist 字段,支持的浏览器版本可以使用通配符以及范围来配置。

// package.json
"browserslist": { 
  "development": [ 
    // 兼容最近的浏览器版本
    "last 1 chrome version", 
    "last 1 firefox version",
    "last 1 safari version" 
  ],
  "production": [ 
    ">0.2%",
    "not dead",
    "not op_mini all" 
  ]
}

通常情况下,在使用 Webpack 进行项目开发时,会默认安装 browserslist 这个库,因为很多 Webpack 插件和 loader 都会使用 browserslist 来进行浏览器兼容性检查和自动添加前缀等操作。而 browserslist 内部使用了 caniuse-lite 数据库来进行浏览器的兼容性查询。

PostCSS 是一个用 JavaScript 实现的 CSS 处理器,通过解析 CSS 并以抽象语法树 AST 的形式存储,从而可以在 AST 层面上对 CSS 进行操作,例如添加前缀、处理嵌套、转换 CSS 语法等。如果需要单独在命令行中使用,应该额外再安装一个 postcss-cli 工具。

同时有很多优秀的插件也是基于 PostCSS 诞生的,如 PreCSS、CSSNext、cssnano、Autoprefixer 等。

{
  test: /\.css$/,
  use: [
    "style-loader",
    "css-loader",
    {
      loader: "postcss-loader",
      options: {
        postcssOptions: {
          plugins: [
            require("autoprefixer")
          ]
        }
      }
    }
  ]
}

事实上,在配置 postcss-loader 时,更多的会使用 postcss-preset-env。postcss-preset-env 将一些现代 CSS 特性转换为大多数浏览器都能理解的 CSS,并自动添加所需的 polyfill。此外,postcss-preset-env 还自动集成了 Autoprefixer 插件,因此不需要单独配置 Autoprefixer。

{
  test: /\.css$/,
  use: [
    "style-loader",
    "css-loader",
    {
      loader: "postcss-loader",
      options: {
        postcssOptions: {
          plugins: [
            require("postcss-preset-env")
          ]
        }
      }
    }
  ]
}

此外,也完全可以将 postcss-loader 的配置移到 postcss.config.js 文件中,代码如下。

// postcss.config.js
module.exports = {
  plugins: [
    require("postcss-preset-env")
  ]
}
use: [ "style-loader", "css-loader", "postcss-loader" ]

如果有在 CSS 文件中通过 @import 导入其他的 CSS 文件,那么这些导入的 CSS 可能不会被 postcss-loader 或者 less-loader 等处理,因为 Webpack 只会对直接引入的 CSS 文件应用 loaders,而不会递归地处理引入的 CSS 文件。

此时可以在使用 css-loader 时配置 importLoaders 选项。importLoaders 选项控制在处理 CSS 文件时,css-loader 应该使用几个额外的 loader 来处理 @import 引入的其他 CSS 文件。例如,在 css-loader 中设置 importLoaders: 1,那么在处理 CSS 文件时,css-loader 将同时使用 postcss-loader 来处理 @import 导入的 CSS 文件。

{
  test: /\.css$/,
  use: [
    'style-loader',
    {
      loader: 'css-loader',
      options: {
        importLoaders: 1 // 这里设置为 1
      }
    },
    'postcss-loader'
  ]
}
{
  test: /\.less$/,
  use: [
    'style-loader',
    {
      loader: 'css-loader',
      options: {
        importLoaders: 2 // 这里设置为 2
      }
    },
    'postcss-loader',
    'less-loader'
  ]
}

使用图片时,常见的两种方式是 img 元素的 src 属性和 CSS 中的 background-image 属性。

The file-loader resolves import/require() on a file into a url and emits the file into the output dir.

The url-loader works like file-loader, but can return a DataURL if a file is smaller than a byte limit.

file-loader 会根据文件的内容使用 MD4 算法生成文件名。但是在某些情况下,可能需要使用占位符来自定义生成的文件名,以确保每张图片都有一个明确的对应关系。此时应考虑 placeholders

{
  test: /\.(png|jpg|gif)$/i,
  loader: 'file-loader',
  options: {
    name: '[name]-[hash].[ext]',
    outputPath: 'images/'
  }
}

开发中,较大的图片文件往往会被存放在单独的目录,通过异步加载的方式来减少页面的加载时间。较小的图片通常会使用 Base64 数据直接嵌入到 HTML 或 CSS 文件,而不作为单独的文件加载。

url-loader 可以将指定大小以下的图片文件转换成 Base64 数据,直接嵌入到生成的 bundle.js 中,从而减少了额外的网络请求,提高页面的性能和加载速度。

{
  test: /\.(png|jpe?g|gif)$/i,
  use: [
    {
      loader: 'url-loader',
      options: {
        limit: 100 * 1024, // 100KB
        name: 'images/[name]-[hash:6].[ext]'
      }
    }
  ]
}

url-loader 默认使用 ESM,而 html-loader 默认使用 CommonJS。如果在使用 url-loader 时,解析出现了 [object Module] 的问题,可能是由于 url-loader 默认使用了 ESM 模块系统,而配置文件中存在与 ESM 不兼容的语法或模块系统。为解决这个问题尝试关闭 url-loader 的 ESM,改为使用 CJS 模块系统解析文件。

rules: [ 
  {
    test: /\.(jpg|png|gif)$/, // 默认处理不了 html 中 img 图片
    loader: "url-loader", // 仅使用一个 loader 可以不需要 use
    options: {
      limit: 8 * 1024, // 图片大小小于 8kb => 被 base64 处理
      esModule: false, 
      name: "[hash:10].[ext]" // 重命名 => [hash:10] 取图片的 hash 的前 10 位
    }
  },
  {
    test: /\.html$/, // 处理图片除样式引入外的 html 标签引入
    loader: "html-loader" 
  } 
]

在 webpack 5 中,可以使用 Asset Modules 替代之前的 url-loader、file-loader 等加载资源的方式。

Asset Modules 可通过 type 属性来指定资源的类型,webpack 会自动将其转换成合适的模块类型。

module.exports = {
  //...
  module: {
    rules: [
      {
        test: /\.(png|jpe?g|gif)$/i,
        type: "asset",
        generator: {
          filename: "images/[name]-[hash][ext]"
        },
        parser: {
          dataUrlCondition: {
            maxSize: 100 * 1024 // 100kb
          }
        }
      }
    ]
  }
}

在处理特殊字体的资源时,可以使用 file-loader 或者 Asset Modules。

在 Asset Modules 中的 [ext] 占位符会自动包含文件名里的扩展名。故在文件名模板中不需要再显式地添加点号,即可以省略 [ext] 前面的点号,如:[name]-[hash]。但对于其他情况,如使用 file-loader 或 url-loader 时,文件名模板需要显式地添加点号。比如:[name].[ext]

rules: [
  {
    test: /\.(eot|ttf|woff2?)$/i,
    type: "asset/resource",
    generator: {
      filename: "font/[name]_[hash:6][ext]" // 此处为 filename 中写目录
    }
  }
]

General Plugins

While loaders are used to transform certain types of modules, plugins can be leveraged to perform a wider range of tasks like bundle optimization, asset management and injection of env variables.

在不清空构建目录的情况下,虽然打包所生成的文件会直接覆盖构建目录中的同名文件,但是仍然会有残余的文件被保留下来。可以使用 clean-webpack-plugin 来自动删除构建目录。

const { CleanWebpackPlugin } = require("clean-webpack-plugin"); // 引入类

module.exports = {
  ...
  plugins: [ new CleanWebpackPlugin() ]
}

html-webpack-plugin 可以根据打包后的结果自动生成 HTML,并自动引入打包后的 JS 和 CSS 文件。

const HtmlWebpackPlugin = require('html-webpack-plugin');

plugins: [
  ...
  new HtmlWebpackPlugin({
    title: "webpack title",
    template: './src/index.html'
  })
]

html-webpack-plugin 默认使用 ejs 模板引擎来生成 HTML 文件。在自定义模板数据填充时,可以使用 DefinePlugin 在编译时创建全局常量。DefinePlugin 是 Webpack 内置的一个插件。

const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  // ...
  plugins: [
    new webpack.DefinePlugin({
      APP_TITLE: JSON.stringify('My App'),
      APP_VERSION: JSON.stringify('1.0.0'),
    }),
    new HtmlWebpackPlugin({
      template: 'src/index.ejs',
      filename: 'index.html',
      inject: true,
    }),
  ],
};
<!DOCTYPE html>
<html>
  <head>
    <title><%= APP_TITLE %> - <%= APP_VERSION %></title>
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

The copy-webpack-plugin copies individual files or entire dirs, which already exist, to the build dir.

copy-webpack-plugin 会根据 output.path 配置项指定的输出目录自动计算出正确的构建目录,因此在 patterns 中的 to 配置项里可以写相对路径,例如 ./ 表示输出目录的根目录。若不设置 to 配置项 copy-webpack-plugin 会默认将文件复制到输出目录的根目录下。

const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
  // ...
  plugins: [
    new CopyWebpackPlugin({
      patterns: [
        {
          from: 'src/assets',
          to: './',
          globOptions: {
            ignore: [
              '**/index.html', // 忽略 src/assets 目录及其子目录下的所有 index.html 文件
              'src/assets/images', // 忽略 src/assets/images 目录及其子目录
            ],
          },
        },
      ],
    }),
  ],
};

Modular Support

webpack 的模块化本质上就是为每个模块创建了一个独立的函数作用域。

在加载模块时,会先根据模块的路径生成一个 moduleId,并将其传递给 __webpack_require__ 函数。__webpack_require__ 函数会使用这个 moduleId 来获取对应的模块工厂函数,然后调用这个函数来获取模块的导出值。

默认 webpack 会为每个模块生成一个独立的模块工厂函数,并将其存储在 __webpack_modules__ 对象(可理解为模块映射)中,键名是模块的路径,值是包装了模块代码的函数。

// 模块工厂函数加载执行模块
__webpack_modules__[moduleId](module, module.exports, __webpack_require__)

Source Map

webpack 中可以通过配置 devtool 选项来生成 source-map。source-map 是一种映射关系,可以将编译后的代码映射回原始源码,使得在浏览器控制台中准确地显示出错误和警告的位置,方便定位和调试问题。

webpack 在 mode: "development" 模式下默认设置 devtool: "eval"。这会将每个模块的源代码转换成字符串,并使用 eval 函数对其执行。利用 eval 包裹代码的末尾注释,可以标记每个模块的位置信息、依赖信息以及代码映射关系等,方便在开发者工具中进行调试。

这些注释被称为 sourceURL 和 sourceMappingURL 注释。其中,sourceURL 注释用于标记 eval 包裹的代码对应的源文件路径和行号信息,而 sourceMappingURL 注释用于标记生成的 source map 文件路径。

source-map 会生成独立的 source-map,并在打包文件中生成指向 source-map 文件的注释,这个注释通常以 //# sourceMappingURL= 开头,后面跟着 source-map 文件的 URL。这个注释会被浏览器解析,然后自动下载对应的 source-map 文件。

eval-source-map 生成的 source-map 是以 DataUrl 的形式添加到 eval 函数后面,而不是作为单独的文件存在。

inline-source-map 会将生成的 source-map 以 DataUrl 的形式添加到打包文件的尾部。

cheap-source-map 中的 cheap 表示低开销,其生成的 source-map 不包含列映射信息,只包含行映射信息。开发中一般使用行映射信息即可定位异常。

cheap-module-source-map 会比 cheap-source-map 包含更多的信息,特别是对于使用了 loader 处理的代码,可以提供更加完整的源代码映射。

常规来说 devtool 的组合规则可以按照如下格式,其中,[inline-|hidden-|eval-] 表示 source map 的嵌入方式,选项 [nosource-] 表示不包含源代码信息,选项 [cheap-[module-]] 表示在仅包含行映射的条件下,是否使用对 loader 转换后的源代码进行更完整信息的显示,最后 source-map 表示生成独立的 source map 文件。

[inline-|hidden-|eval-][nosource-][cheap-[module-]]source-map

开发和测试阶段推荐选择 source-mapcheap-module-source-map

为提高代码运行效率和减小文件体积,打包阶段通常不包含源代码映射,推荐缺省或 false

Babel

巴别塔 Babel 是一个工具链,可以将 ECMAScript 2015+ 代码转换为支持在当前和旧版本浏览器环境中运行的 JavaScript 版本(向后兼容)。

在开发环境依赖中安装 Babel 核心模块 @babel/core 和命令行工具 @babel/cli。

  • @babel/core 提供了 Babel 的编译功能,包括解析源码、转换和生成目标代码等
  • @babel/cli 提供了在命令行中使用 Babel 的能力,可以通过命令行参数指定相关的操作
$ npm install --save-dev @babel/core @babel/cli

使用 Babel 来转换箭头函数和块级作用域时,需要额外安装两个插件 @babel/plugin-transform-arrow-functions 和 @babel/plugin-transform-block-scoping。

$ npm install --save-dev @babel/plugin-transform-arrow-functions @babel/plugin-transform-block-scoping
$ npx babel src --out-dir dist --plugins=@babel/plugin-transform-arrow-functions, @babel/plugin-transform-block-scoping

@babel/preset-env 是一个预设插件集合,提供了根据当前的环境自动确定需要使用哪些插件来进行语法转换的能力。@babel/preset-env 预设插件集合在使用时,可以通过设置 targets 选项来指定转换的目标执行环境。这个选项可以是一个对象,也可以是一个字符串。

$ npm install --save-dev @babel/preset-env 
$ npx babel src --out-dir dist --presets=@babel/preset-env --targets '{"chrome": "58", "ie": "11"}'

targets 以外,@babel/preset-env 还提供了一些其他的选项,用于控制预设插件集合的行为。其中比较常用的有:根据目标环境自动导入所需的 polyfill 的 useBuiltIns 和在控制台输出详细的调试信息,包括每个插件的名称、版本和选项的 debug.babelrcbabel.config.js 中添如下。

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "usage", // 自动根据使用的特性来决定是否导入 polyfill
        "corejs": 3,
        "debug": true
      }
    ]
  ]
}

@babel/polyfill 是一个 JavaScript lib,提供对 ECMAScript 新特性的 polyfill 支持,以使这些特性在旧版浏览器中也可以生效。其包含两个主要的部分:core-js 和 regenerator-runtime。

core-js 是一个模块化的标准库,为 ECMAScript 的各种特性提供 polyfills,以解决不同浏览器之间的兼容性问题。包括 Promise、Map、Set、Reflect、Proxy、Symbol 等。

npm install core-js@3 --save
# or
npm install core-js@2 --save

regenerator-runtime 是一个运行时库,提供了对于 ECMAScript 6 generators 和 async/await 语法的支持。通常用来处理生成器和异步函数。

提示:@babel/polyfill 在 Babel 7.4.0 中已经被弃用了,建议使用 @babel/preset-env 中的 useBuiltIns 选项来引入需要的 polyfill。只需指定 corejs 版本,不需要额外指定 regenerator-runtime。

插件 @babel/plugin-transform-runtime 可以将代码中使用到的一些辅助函数进行替换,以实现在不污染全局命名空间的情况下使用(如 Object.assign、Promise、Symbol 等)。这可以解决 polyfill 机制中将新增的静态方法和实例方法直接添加到全局变量或全局变量原型上的问题(与第三方库产生冲突)。

yarn add @babel/plugin-transform-runtime -D
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "usage", // 如果 @babel/plugin-transform-runtime 配置了 corejs:3 => preset-env 的 useBuiltIns 就不会生效
        "debug": true,
        "targets": {
          "ie": 10
        },
        "comments": false // 不产生注释
      }
    ]
  ],
  "plugins": [
    [
      // 转换为引用 babel-runtime/regenerator 和 babel-runtime/core-js 模块中的方法
      "@babel/plugin-transform-runtime",
      {
        "corejs": 3 // 指定 runtime-corejs 的版本 => 目前有 2、3
      }
    ]
  ]
}

Babel 的执行过程其实和很多编译器的工作原理是类似的。

  • 解析:使用解析器 Parser 将源码解析成 AST 抽象语法树
  • 转换:使用转换器 Transformer 对 AST 进行修改,并应用插件 Plugins 对特定的语法进行转化
  • 生成:使用生成器 Generator 将修改后的 AST 转换为目标代码,可以被 V8 引擎解释执行

解析一般包括词法分析和语法分析两个阶段。词法分析会将源代码转换成记号流,而语法分析会分析这些 tokens 流并将其转换成一颗抽象语法树 AST。

当需要将 TypeScript 转换为 JavaScript 时,可以结合使用 babel-loader 与 tsc,以弥补这两者各自的不足。babel-loader 在编译时不会对类型错误进行检测,仅使用 tsc 会缺少解决兼容性问题的 polyfill。

具体可以在 package.json 中添加脚本 "type-check-watch": "tsc --noEmit --watch" 来开启类型检查,在 webpack 中使用 babel-loader 进行编译转换和解决兼容性的问题。

Advanced Support

Transitions for Vue SFC

在将 Vue 单文件组件转换为 JavaScript 模块时,需要借助 vue-loader 与 vue-template-compiler。前者可以将一个 .vue 文件转换为 JavaScript 对象。后者会将 .vue 文件中的 <template> 标签编译为渲染函数,最终生成实际的 DOM 结构并进行渲染,以便在浏览器中可以显示相关的组件。

$ npm install vue-loader vue-template-compiler -D

配置 vue-loader 时需要在 webpack 的配置文件中使用 VueLoaderPlugin 插件。具体来说,需要在配置文件中的 plugins 数组中创建一个 VueLoaderPlugin 的实例,并将其作为插件添加进去。

const VueLoaderPlugin = require('vue-loader/lib/plugin');

module.exports = {
  // ...其他配置
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },
      // ...其他规则
    ]
  },
  plugins: [
    new VueLoaderPlugin()
  ]
};

DevServer and HMR

通常在开发阶段需要频繁地修改和测试代码,如果每次都通过手动编译和刷新浏览器,那么开发效率就会受到影响,此时可以使用 devServer 在内存中编译打包,并提供一个自动刷新浏览器的开发环境。

虽然使用 watch 参数或者 live-server 插件也可以实现代码修改后的页面更新,但效率不如 devServer 高。因为 watch 参数或者 live-server 插件都是直接刷新整个页面,而 devServer 是通过 HMR 热模块替换技术实现了页面的局部更新,从而避免了刷新整个页面的开销。

...
module.exports = {
  mode: 'development',
  devServer: {
    contentBase: resolve(__dirname, 'build'), // 项目构建后路径
    compress: true, // 启动 gzip 压缩
    port: 3000, // 端口号 
    open: true // 自动打开浏览器 
  } 
};

需要注意的是 webpack-cli 的版本,webpack-cli 4 已经将 devServer 的实现方式进行了重构,需要使用 webpack serve 来启动开发服务器,而 webpack-cli 4 以前的版本,使用 webpack-dev-server

webpack-dev-middleware 是一个 Express 中间件,可以将 webpack 打包的文件传递给服务器,并且可以将打包结果缓存到内存中,以便在文件发生变化时自动重新构建。

const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');

const app = express();
const config = require('./webpack.config.js');
const compiler = webpack(config);

app.use(webpackDevMiddleware(compiler, {
  publicPath: config.output.publicPath
}));

app.listen(3000, function () {
  console.log('App listening on port 3000!\n');
});

如果没有在入口文件中通过 module.hot.accept 函数来指定需要开启 HMR 的模块,那么默认情况下只有在根模块发生变化时,整个应用程序才会被热更新,而其他模块则会触发完整刷新。点此查看

if (module.hot) {
  module.hot.accept('./your-module', function() {
    // 当 './your-module' 模块更新后执行的逻辑
  })
}

在使用 Vue 或 React 框架开发时,社区已经提供了比较成熟的 HMR 解决方案,点此查看。注意 React Hot Loader 现已被官方弃用,改为 react-refresh 方案。

HMR 热更新的实现原理是 webpack-dev-server 或 webpack-dev-middleware 将打包好的文件传递给服务器 Express,同时建立一个长连接的 socket 服务,监听文件的变化事件。当某个模块的代码发生变化时,webpack 会重新打包这个模块的代码,然后通过 socket 服务推送给客户端的浏览器。

Code Splitting

代码分割作为一种常规的优化手段,可以将打包后的代码拆分成多个小块,然后可以按需加载或并行加载这些文件。

在使用入口起点的方式进行代码分割时,每个入口文件都会被单独的打包。同时,还可以使用的占位符来指明打包后的 bundle 文件名。

module.exports = {
  entry: {
    app: './src/app.js',
    vendor: './src/vendor.js'
  },
  output: {
    filename: '[name].[contenthash:8].js',
    path: __dirname + '/dist'
  }
};

当多个入口文件使用了相同的第三方包时,如果不进行处理,这些第三方包可能会被重复打包,导致打包后的文件体积过大。为避免这种情况,可使用 Entry dependenciesSplitChunksPlugin

optimization.splitChunks.chunks 的默认值是 async,这意味着在 webpack 中,异步或动态导入的文件会被打包成一个独立的 chunk。

注意,该 chunk 的名称是自动生成的 chunk id,具体生成规则受到 optimization.chunkIds 配置的影响。可以通过 webpackChunkName 这种 magic comments 来自定义生成的 chunk 名称。

import(
  /* webpackChunkName: "utils" */
  "./utils"
).then({default: utils}) => { utils(); });

Tree Shaking

Tree shaking is a term commonly used in the JavaScript context for dead-code elimination. It relies on the static structure of ES2015 module syntax, i.e. import and export. The name and concept have been popularized by the ES2015 module bundler rollup. More.

optimization.usedExports 设置为 true 时,Webpack 会在编译过程中生成一些帮助 Terser 进行代码优化的注释。例如,当模块中有某个函数被导出,但是并没有在项目里被实际的使用到,那么会产生 unused harmony export ... 注释。

然而,要使 Tree Shaking 真正的生效,还需要配置 optimization.minimize: true,否则,虽然注释已经生成,但 optimization.minimizer 中的插件没有被启用,Terser 就不会进行代码优化。

在进行上述配置后,仍然可能存在一些残余代码,例如在打包文件中存在无意义的导入。此时,可以通过设置 package.json 中的 "sideEffects" 属性来标记哪些模块具有副作用,进一步优化摇树效果。

CSS Tree Shaking 可以选择 PurgeCSS,早期的 PurifyCSS 方案现已不再维护。

UglifyJS VS Terser

Terser 和 UglifyJS 都是压缩和混淆 JavaScript 代码的工具,用于在构建过程中减小代码体积。Terser 在 UglifyJS 的基础上进行了改进和优化,并且支持 ES6+ 语法,可以处理箭头函数、模板字符串、解构赋值等新特性。此外,精确 Tree Shaking 以及并发压缩也让 Terser 更好地支持现代项目。

HTTP & HTML COMP

HTTP compression 可以减小 Server 与 Client 之间的数据量。Client 请求资源时携带 Accept-Encoding 请求头,表明支持哪些压缩算法。如果服务端支持其中一种压缩算法并且有配置为压缩响应,就可以在响应中包含一个 Content-Encoding 响应头,以指示响应已被压缩。

webpack 中,可以通过 compression-webpack-plugin 插件来启用 HTTP 压缩。compression-webpack-plugin 会在编译时自动检测所有的资源文件,将其压缩后输出,并在响应中添加 Content-Encoding。

对 HTML 文件进行压缩可以使用 HtmlWebpackPlugin 插件中的 minify 属性。如果需要将 chunk 出来的模块(如运行时代码)内联到 HTML,可以使用 react-dev-utils 中的 InlineChunkHtmlPlugin。

const HtmlWebpackPlugin = require('html-webpack-plugin');
const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin');

module.exports = {
  // 其他配置项
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
      // 压缩 HTML 代码
      minify: {
        removeComments: true, // 移除注释
        collapseWhitespace: true, // 折叠空白字符
        removeRedundantAttributes: true, // 移除冗余的属性
        useShortDoctype: true, // 使用短的 <!DOCTYPE html> 声明
        removeEmptyAttributes: true, // 移除 HTML 元素中的空属性
        removeStyleLinkTypeAttributes: true, // 移除 type="text/css" 属性
        keepClosingSlash: true, // 保留自闭合标签的末尾斜杠
        minifyJS: true, // 压缩内联 JS
        minifyCSS: true, // 压缩内联 CSS
        minifyURLs: true, // 压缩 URL
      },
    }),
    // 将 chunk 内联到 HTML 中
    new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime.*\.js/]),
  ],
};

Tips and Hints

Operators && and &

&& 运算符会将两个命令连接起来,并当第一个命令执行成功后再执行第二个命令。

& 运算符是将两个命令同时执行,不需要等待第一个命令的执行完毕,常用于同时执行多个任务。

publicPath in output and devServer

配置项 output.publicPathdevServer.publicPath 都是相对于打包输出目录的 URL,这个选项会成为引用静态资源时的前缀。两者的区别是客户端访问静态资源和访问开发服务器上的静态资源。

Entry and Context

The context is the base directory, an absolute path, for resolving entry points and loaders from the configuration.

entry 通常使用相对路径,并且相对于配置中的 context 属性。context 属性是一个用于解析相对路径的绝对路径,这个绝对路径可以是任何存在的目录,但通常会选择项目的根目录作为上下文。

Filename and ChunkFilename

output.filename 用于指定入口起点生成的 bundle 文件名,而 output.chunkFilename 用于指定非入口起点生成的文件名,例如懒加载这种动态导入的代码块的文件名格式。注意,webpackChunkName 优先级高于 output.chunkFilename 高于默认的 chunk id。

Hash & ChunkHash & ContentHash

hash 是通过 webpack 编译过程中的一些信息(如打包时的模块内容、打包时间等)计算得出的。

chunkhash 是通过 chunk 内容计算的,当 chunk 内容发生变化时,该哈希值才会发生变化。

contenthash 是通过文件内容计算的。当文件内容发生变化时,该哈希值才会发生变化。

fullhash 是通过整个项目的内容计算得到的,只有在项目内容发生变化时才会生成新的文件名。

注意,chunkhash 会随着引入的模块内容变化而发生改变,contenthash 常应用于静态资源的文件名,以避免不必要的缓存失效。因需要计算所有资源的哈希值,fullhash 的生成速度要比 hash 慢很多(fullhash 会计算静态资源的哈希值,但是 hash 不会)。

webpack-dev-server 兼容问题

运行webpack-dev-server出现如下报错

> webpack-dev-server

internal/modules/cjs/loader.js:883
  throw err;
  ^

Error: Cannot find module 'webpack-cli/bin/config-yargs'

报错内容为找不到 webpack-cli 中对应模块。报错时项目相应 webpack 配置如下:

"webpack": "^5.24.3",
"webpack-cli": "^4.5.0",
"webpack-dev-server": "^3.11.2"

解决方案:

  • 方法一:重装 webpack-cliwebpack-dev-server 兼容的版本
  • 方法二:添加 "dev":"webpack serve --open Chrome"package.json 中的 "script" 选项

Gzip Compression

设置 devServer.compress: true 可以启用开发服务器的 Gzip 压缩功能,但这并不会影响打包后的资源文件。为了在生产环境中也能使用 Gzip 压缩,可以借助 compression-webpack-plugin 插件。

npm install compression-webpack-plugin --save-dev
// vue.config.js
const CompressionPlugin = require('compression-webpack-plugin');

module.exports = defineConfig({
  configureWebpack: {
    plugins: [
      new CompressionPlugin({
        test: /\.(js|css|html|svg|json|ico|woff|ttf)$/, // 需要压缩的文件类型
        threshold: 10240, // 10KB 以上的文件才会被压缩
      }),
    ],
  },
});

发送请求时,Accept-Encoding 字段会由浏览器自动设置。当服务端返回压缩的资源时,也通常会在响应头中设置 Content-Encoding: gzip,告知浏览器该资源已被压缩。在 Express 中启用 Gzip 压缩可以使用 compression 中间件。Nginx 可以在配置文件中通过 location 来指定哪些请求需要启用 Gzip 压缩。

gzip on;
gzip_min_length 1000;
gzip_types text/plain application/xml application/javascript;

然而,并不是所有的项目都需要启用压缩。当项目非常小,并且没有大量的静态资源需要传输,那么启用压缩可能并不会带来显著的性能提升。相反,甚至会增加服务器的 CPU 负担。

Black Box Analysis

Deprecated Configuration

Dynamic Linking Library

DDL 动态链接库通过 DllPlugin 插件来实现。该插件会将指定的模块打包成一个动态链接库,并生成一个 manifest 文件,用于描述动态链接库的内容和对应的模块名称。

在项目根目录下,创建一个名为 webpack.dll.config.js 的配置文件,并在其中配置 DllPlugin 插件,指定需要打包为动态链接库的模块。

const path = require('path');
const { DllPlugin } = require('webpack');

module.exports = {
  mode: 'production',
  entry: {
    vendor: ['react', 'react-dom', 'lodash'], // 需要打包为动态链接库的模块
  },
  output: {
    filename: '[name].dll.js',
    path: path.resolve(__dirname, 'dll'),
    library: '[name]',
  },
  plugins: [
    new DllPlugin({
      name: '[name]',
      path: path.resolve(__dirname, 'dll/[name].manifest.json'),
    }),
  ],
};

然后,在 package.json 中添加一个脚本命令,用于执行构建 DDL 动态链接库的任务。

运行 npm run build:dll 会执行构建动态链接库的操作,根据配置文件 webpack.dll.config.js,将指定的模块打包成一个名为 vendor.dll.js 的动态链接库,并生成一个 vendor.manifest.json 的 manifest 文件。

{
  "scripts": {
    "build:dll": "webpack --config webpack.dll.config.js"
  }
}

在项目的主配置文件中,使用 DllReferencePlugin 插件引用刚刚生成的动态链接库。

const path = require('path');
const { DllReferencePlugin } = require('webpack');

module.exports = {
  mode: 'development',
  entry: {
    main: './src/index.js', // 项目的入口文件
  },
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  plugins: [
    new DllReferencePlugin({
      context: __dirname,
      manifest: require('./dll/vendor.manifest.json'),
    }),
  ],
};

最后,在项目的入口文件 index.js 中,可以正常引用 DDL 动态链接库中的模块。

import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.render(<App />, document.getElementById('root'));

除开 Webpack 提供的 DllPlugin 和 DllReferencePlugin 插件,配置选项 Externals 也可以避免将某些外部依赖库打包进业务代码,而通过 script 标签或者其他方式在运行时从外部引入这些依赖。

假设有一个大型的项目,项目依赖了很多第三方库,例如 react、react-dom、lodash、axios 等。这些第三方库的版本比较稳定,不经常变动。在这种情况下,可以考虑使用 DllPlugin 和 DllReferencePlugin 将这些库预先打包成 DLL 文件。这样在每次构建项目时,Webpack 不需要重新解析和打包这些库,从而大大缩短构建时间。而对于简单的小型项目,可能只需要引入一个或两个第三方库,或者直接使用 CDN 的方式引入一些库,这时可以选择使用 Externals 将这些库排除在构建过程之外,减小输出文件的体积。

HappyPack

HappyPack 是一个可以将 Webpack 的任务分解成多个子进程并行处理的插件,更高效地利用多核 CPU。然而,随着 Webpack 的不断发展和优化,HappyPack 已经不再被推荐使用了。

如果希望进一步优化构建性能,可以通过 thread-loader 来替代 HappyPack。thread-loader 将耗时较长的 Loader(如 Babel 或 TypeScript)放入单独的 worker 池中,并在 worker 池中使用多线程并行处理这些任务。

通过 NPM 安装 thread-loader,并将 thread-loader 添加到耗时较长的 Loader 配置之前。

npm install thread-loader --save-dev
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        // 将 thread-loader 添加到 babel-loader 之前
        use: ['thread-loader', 'babel-loader'],
        exclude: /node_modules/,
      },
      // ...其他规则
    ],
  },
  // ...其他配置
};

有需要可以在 thread-loader 中配置 worker 池的大小,即并行处理任务的线程数,默认为 os.cpus().length

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: 'thread-loader',
            options: {
              // 指定 worker 池的大小
              workers: os.cpus().length - 1, // 默认是 os.cpus().length
            },
          },
          'babel-loader',
        ],
        exclude: /node_modules/,
      },
      // ...其他规则
    ],
  },
  // ...其他配置
};

thread-loader 可以提高构建速度,但也可能会增加构建过程中的内存开销。在实际开发中,应该根据项目的具体情况和硬件资源,合理配置 worker 池的大小,避免过度占用系统资源。

结束

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!

]]>
<![CDATA[React]]>https://zairesinatra.github.io//react/60f6b68a6aa9bdead2d22794Thu, 04 Mar 2021 11:42:00 GMT

快速上手

React

React 主要负责 MVC 中视图层 View 的渲染。库 react 和 react-dom 分别用于创建和渲染元素。

  • React.createElement 的参数分别是元素名、元素属性和子节点|文本节点
  • render 的参数是待渲染元素和页面元素挂载点;返回值描述页面的内容
import React from 'react'; // 提供 React.StrictMode
import ReactDOM from 'react-dom/client';
...
const title = React.createElement('h1', null, 'Hello React') // 创建 react 元素
ReactDOM.render(title, document.getElementById('root')) // 渲染 react 元素到页面

JSX => JavaScript XML

React embraces the fact that rendering logic is inherently coupled with other UI logic => 事件处理、状态改变、数据渲染 -> 互相影响

Since JSX is closer to JavaScript than to HTML, React DOM uses camelCase property naming convention instead of HTML attribute names.
class => className in JSX, tabindex => tabIndex, and for => htmlFor

/* input 标签点 label 获取焦点 */
<label for="username">username:</label> // Native
<label htmlFor="username">username:</label> // React
<input type="text" id="username"></input>

作为 React.createElement 的语法糖,JSX 是经 @babel/preset-react 包下的 Babel 配置完成转义。React.createElement 调用后会产生称作 React 元素的对象。

const element = ( <h1 className="greeting"> Hello, world! </h1> ); // 转义前
const element = React.createElement( 'h1', {className: 'greeting'}, 'Hello, world!' );
// React 通过读取这些对象来构建 DOM 以及保持随时的更新
const element = { type: 'h1', props: { className: 'greeting', children: 'Hello, world!' } };
  • JSX 是 React 声明式的体现,React.createElement 是命名式的体现
  • JSX 中可以使用花括号嵌入 JavaScript 表达式,不可以嵌入语句

语句会产生能用变量接收结果值,可以放在任何需要值的位置。不同于依靠框架提供的语法增强 HTML 结构,React 利用语言自身能力编写结构的需要。

由于没有指令的概念,条件渲染需要通过标识变量,列表渲染需要在花括号中使用数组原型的 map 方法。

  • React 在样式处理中推荐行内样式而非类名,开发中推荐使用 className
// 行内样式可以具有多个 => 对象形式
var styleObj = {color: 'black', backgroundColor: 'white'}
<h1 style={styleObj}> JSX 样式 </h1>
<h1 style={{color: 'black', backgroundColor: 'white'}}> JSX 样式 </h1> // 双花括号不是语法 => 只有单花括号是

Components

函数组件没有状态,接收唯一带有数据的 props 属性对象,并返回一个 React 元素。函数名必须用大写字母开头,以区分组件与普通 React 元素

function Hello(props) { return <h1>Hello, {props.name}</h1>; }
const Hello = (props) => <div>Hello, {props.name}</div>

类组件使用 ES6 的 class 定义,普通类继承 React.Component 才能成为组件类

class Hello extends React.Component { render() { return <h1>Hello, {this.props.name}</h1>; } }

类组件的具体渲染效果依靠渲染函数,所以渲染函数必须有表示该组件结构的返回值。解析类组件标签时,会先 new 出该类的实例,并调用原型上的 render 方法。

ReactDom.render(<App>, document.getElementById("#root")) // 自动转换以下写法
ReactDom.render(new App({name: react}).render(), document.getElementById("#root")) 

函数组件和类组件在不需要渲染内容时可直接返回 null。

除非要对实例初始化指定属性,类组件的构造器不是必须的。当子类继承父类,且子类指定构造器时,子类构造器中的 super 函数必须调用。因为子类继承父类时没有 this,需通过 super 绑定父类的 this,同 sup.prototype.constructor.call(sub)。

  • 组件实例属性 state & props

自定义组件会将 JSX 所接收的属性 attributes 以及子组件 children 转换为单个对象传递给组件,这个对象被称之为 props。
组件无论是使用函数声明还是通过 class 声明,都绝不能修改自身的 props。

function Hello(props) { return <h1>Hello, {props.name}</h1>; }
// class Hello extends React.Component { render() { return <h1>Hello, {this.props.name}</h1>; } }
const root = ReactDOM.createRoot(document.getElementById('root'));
const element = <Hello name="IcedAmericano" />;
root.render(element);

state 与 props 类似,但是 state 是私有的,并且完全受控于当前组件,是组件的内部状态。

直接修改 state 并不会重新渲染组件,state 修改应该使用 setState()。setState() 的作用是修改 state 并更新页面。构造函数是唯一能给 this.state 赋值的位置。

this.state.comment = 'Hello'; // Wrong
this.setState({comment: 'Hello'}); // Correct

state 的更新可能是异步的。出于性能考虑,React 可能会把多个 setState() 调用合并成一个调用。因为 this.props 和 this.state 可能会异步更新,所以不要依赖这些值来更新下一个状态。要解决此问题,可以让 setState() 接收函数而不是对象。

传入对象参数是传入函数参数的语法糖。若新状态不依赖于原状态,可以使用对象参数;若新状态依赖于原状态,需要使用函数参数。

this.setState({ counter: this.state.counter + this.props.increment }); // Wrong
this.setState((state, props) => ({ counter: state.counter + props.increment })); // Correct
this.setState(function(state, props) { return { counter: state.counter + props.increment }; }); // Correct

setState() => 是主线程调用的同步方法,但其更新数据状态的动作是异步的。

函数 render 中不能调用 setState()。渲染函数作为纯函数,其返回结果完全取决于 this.state 和 this.props,期间不应该造成任何副作用(副作用即状态改变)。

React 单向数据流 => 任何的 state 总是所属于特定的组件,且从该 state 派生的任何数据或 UI 只能影响树中"更低"的组件。

  • 列表渲染 key 应就近数组上下文;在 map() 方法中元素需要设置 key 属性

  • 受控组件 => React 的表单元素自行维护 state,并根据用户输入进行更新

Handling Events

React 事件的命名采用 camelCase,而不是纯小写;在 JSX 语法中需要传入函数作为事件处理函数,而不是字符串。

<button onclick="btnFunc()">Click me</button> // 原生标签内调用函数

类组件绑定事件函数时需要用到 this,代表指向当前的类的引用;函数组件中不需要调用 this。

<button onClick={btnFunc}>Click me</button> // React

元素在 JSX 中所绑定的事件先会经 Babel 转换成 React.createElement 的形式,再转成 fiber 对象。对象属性 memoizedProps 和 pendingProps 保存具体事件。

<button onClick={ this.handerClick }>Click Me</button>
React.createElement ("button", { onClick: this.handerClick }, "\u{43}\u{6c}\u{69}\u{63}\u{6b}\u{20}\u{4d}\u{65}")
child: FiberNode { memoizedProps: {children: "Click Me", onClick: f}, pendingProps: {children: "Click Me", onClick: f} }

React 17 不再将事件添加于 document,而是绑定根节点容器。方便局部升级。

const rootNodeContainer = document.getElementById('root');
ReactDOM.render(<App />, rootNodeContainer);

事件不会注册在具体元素节点,而是采取事件代理模式,利用冒泡指定事件处理程序,等冒泡到根节点再通过 event.target 找到真实触发的事件源。

// 真实节点上的处理函数被替换为空函数 noop
elementName
  useCapture: false
  passive: false
  once: false
  handler: f noop()

并非在初始时将所有事件绑定在根节点容器上,而是采取按需绑定,在发现具体合成事件后,再去绑定根节点容器的原生事件。事件触发时,通过调度离散事件函数 dispatchDiscreteEvent 将指定的函数执行。

React 合成事件 SyntheticEvent 是模拟原生 DOM 事件所有能力的事件对象,即浏览器原生事件的跨浏览器包装器。

此外不能通过返回 false 的方式阻止默认行为。必须显式地使用 preventDefault。

// 传统的 HTML 中阻止表单的默认提交行为
<form onsubmit="console.log('You clicked submit.'); return false">
  <button type="submit">Submit</button>
</form>
// React
function Form() {
  function handleSubmit(e) { e.preventDefault(); console.log('You clicked submit.'); }
  return (
    <form onSubmit={handleSubmit}>
      <button type="submit">Submit</button>
    </form>
  );
}
  • this for the methods in JSX

render 中 this 指向类组件实例,与 render 平级的方法中 this 为 undefined。

处理事件回调函数中 this 的指向时,通常用箭头函数与 bind 绑定。

/* 方式一 => 在 render 中使用行内箭头函数 */
class ArrowFunctionBindTest extends React.Component {
  // handleFunc() { console.log('this is:', this); };
  handleFunc = () => { console.log('this is:', this); };
  render() {
    return (
      // 事件处理函数用箭头函数表示 => 回调函数可使用箭头函数表示或函数声明表示
      // 箭头函数 this 指向外部函数的 this => 回调函数因自主调用 this 指向类组件实例
      <button onClick={() => this.handleFunc()}>
        Click me
      </button>
    );
  }
}
/* 方式二 => 组件内使用箭头函数定义方法 */
class ArrowFunctionBindTest extends React.Component {
  handleFunc = () => { console.log('this is:', this); }; // 回调函数用箭头函数表示
  render() {
    return (
      <button onClick={ this.handleFunc }>
        Click me
      </button>
    );
  }
}
/* 方式三 => 在 render 中对事件处理函数绑定 this */
class ArrowFunctionBindTest extends React.Component {
  handleFunc() { console.log('this is:', this); }
  // handleFunc = () => { console.log('this is:', this); };
  render() {
    return (
      // 事件处理函数用 bind 绑定 => 回调函数可使用箭头函数表示或函数声明表示
      <button onClick={ this.handleFunc.bind(this) }>
        Click me
      </button>
    );
  }
}
/* 方式四 => 在构造器中绑定 this */
class ArrowFunctionBindTest extends React.Component {
  constructor(props) {
    super(props)
    // 原型 handleFunc 中 this 指向实例 => bind 的函数挂载到实例自身并命名为 handleFunc
    this.handleFunc = this.handleFunc.bind(this)
  }
  handleFunc() { console.log('this is:', this); }
  // handleFunc = () => { console.log('this is:', this); };
  render() {
    return (
      // 事件处理函数用 bind 绑定 => 回调函数可使用箭头函数表示或函数声明表示
      <button onClick={ this.handleFunc }>
        Click me
      </button>
    );
  }
}
/* 方式五 => 事件处理函数用箭头函数表示且直接写回调函数 */
class ArrowFunctionBindTest extends React.Component {
  render() {
    return (
      <button onClick={ () => { console.log('this is:', this); } }>
        Click me
      </button>
    );
  }
}

自行封装的组件进行 props 校验

  1. 安装:yarn add prop-types
  2. 导入 PropTypes:import PropTypes from 'prop-types'
  3. 给组件属性添加 props 校验:
组件.propTypes = {
    属性1: PropTypes.string.isrequired,
    属性2: PropTypes.func
}

React Hooks => React16.8

Hook 可以在不编写类组件的情况下使用状态以及其他的 React 特性。

React 复用组件的逻辑通常会采用 render prop 或 HOC,但这会存在嵌套地狱的问题。Hook 从组件中提取状态逻辑,即可在无需修改组件结构的情况下复用状态逻辑。

Hook 本质是 JavaScript 函数。Hook 只在最顶层使用、只在 React 函数中调用。

自变量 Hook

  • 纯函数组件没有状态 => 通过 useState 为函数组件引入状态
setXxx(newValue) or setXxx(value => newValue)

useState 接受状态的初始值作为参数,并返回一个数组。useState 的返回值通过数组解构创建状态变量和更新状态变量的函数。

import React, { useState } from "react";

function UseStateHook() {
  const [name, setName] = useState("hello useState");
  const [age, setAge] = useState(22);
  const [work, setWork] = useState("frontendenv");
  return (
    <div>
      <span>{name}</span>&nbsp;&nbsp;
      <span>{age}</span>&nbsp;&nbsp;
      <span>{work}</span>&nbsp;
      <br />
      <button
        onClick={() => {
          setName((name) => name.split("").reverse().join(""));
        }}
      >
        change my name
      </button>
      <button
        onClick={() => {
          setAge(age + 1);
        }}
      >
        click me add age
      </button>
      <button
        onClick={() => {
          setWork("fullstack");
        }}
      >
        what's my field
      </button>
    </div>
  );
}
export default UseStateHook;

若使用单个状态变量,每次更新状态时需要合并之前的状态。类组件 setState 会把更新的字段自动合并到状态对象,而 useState 返回的 setXxx 会替换原来的值。

const [state, setState] = useState({ key01: val01, key02: val02, key03: val03, key04: val04 });
setState(state => ({ ...state, key01: val001, key02: val002 }));

不相关的状态推荐拆分为多组状态变量;相互关联或互相依赖的的状态建议合并为单组状态变量。把独立的状态变量拆分开还有另外的好处。在后期将一些相关的逻辑抽取到一个自定义 Hook 变得更容易。

function Box() {
  const position = useWindowPosition();
  const [size, setSize] = useState({ width: 100, height: 100 });
  // ...
}

function useWindowPosition() {
  const [position, setPosition] = useState({ left: 0, top: 0 });
  useEffect(() => {
    // ...
  }, []);
  return position;
}
  • 组件之间共享状态 Hook => useContext

Context 上下文状态分发用于替代 React 逐层以 props 传递的全局数据。

Context 常用 API => React.createContext、Context.Provider、Context.Consumer、Class.contextType、Context.displayName

当组件上层最近的 <XxxContext.Provider> 更新时,该 Hook 会触发重渲染,并使用最新传递的数据。不受限于 React.memo 或 shouldComponentUpdate。

上下文对象创建时即可初始化传递默认值作参数。实际以 <XxxContext.Provider> 标签中 value prop 决定当前值。

const XxxContext = React.createContext(_defaultValue);
...
<XxxContext.Provider value=_finalValue>
  <Isolation />
</XxxContext.Provider>

useContext Hook 的参数必须是上下文对象本身 => useContext(XxxContext)。

const _xxx = useContext(XxxContext);
...
_variable01: _xxx.attr01
import React, { useState, createContext, useContext } from "react";
const CountContext = createContext();
function UseContexthook() {
  const [count, setCount] = useState(6);
  return (
    <div>
      <p>You clicked {count} times</p>
      <button
        onClick={() => {
          setCount(count + 1);
        }}
      >
        click me
      </button>
      <CountContext.Provider value={count}>
        <Isolation />
      </CountContext.Provider>
    </div>
  );
}
function Isolation() { // 隔离组件
  return (
    <Counter />
  )
}
function Counter() {
  const count = useContext(CountContext); //一句话就可以得到count
  return <h3>{count}</h3>;
}
export default UseContexthook;
  • 状态关联复杂的 useState 替代方案 => useReducer

useReducer 适用于多方式更新 state,或依赖于 oldState 的情况。dispatch 可以向深层级的子组件传递。

const [state, dispatch] = useReducer(reducer, initialArg, init);

reducer => 接收状态 state 和动作 action,返回与 dispatch 配套的状态更新逻辑。

function reducer(state, action) {
  switch (action.type) {
    case 'type01':
      return {xxx: state.xxx + operation01};
    case 'type02':
      return {xxx: state.xxx + operation02};
    default:
      throw new Error();
  }
}
...
<button onClick={() => dispatch({type: 'type01'})}>operation01</button>

惰性初始化创建初始 state => 将 init 函数作为 useReducer 的第三个参数传入,初始 state 将被设置为 init(initialArg)。

这么做可以将用于计算 state 的逻辑提取到 reducer 外部,这也为将来对重置 state 的 action 做处理提供便利。

import React, { useReducer } from "react";

const initCount = 0;

const init = (initCount) => {
  return { count: initCount };
};

const reducer = (state, action) => {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
    case "reset":
      return init(action.payload || 0);
    default:
      throw new Error();
  }
};
const UseReducerHook = () => {
  const [state, dispatch] = useReducer(reducer, initCount, init);

  return (
    <div className="App">
      <div>useReducer Count:{state.count}</div>
      <button
        onClick={() => {
          dispatch({ type: "decrement" });
        }}
      >
        useReducer 减少
      </button>
      <button
        onClick={() => {
          dispatch({ type: "increment" });
        }}
      >
        useReducer 增加
      </button>
      <button
        onClick={() => {
          dispatch({ type: "reset", payload: 999 });
        }}
      >
        resetTo 999
      </button>
    </div>
  );
};
export default UseReducerHook;

如果 Reducer Hook 的返回值与当前 state 相同,React 将跳过子组件的渲染及副作用的执行。(React 使用 Object.is 比较算法 来比较 state。)

需要注意的是,React 可能仍需要在跳过渲染前再次渲染该组件。不过由于 React 不会对组件树的“深层”节点进行不必要的渲染,所以大可不必担心。如果你在渲染期间执行了高开销的计算,则可以使用 useMemo 来进行优化。

因变量 Hook

  • 生命周期替代 => useEffect Hook

类组件通过生命周期函数处理副作用操作,但是存在编写重复代码的弊端。此时可以在组件内部调用 useEffect 直接访问 state 变量或其他 props。

默认 useEffect 在渲染(第一次渲染之后和每次更新)之后都会执行。

渲染后执行的副作用函数称为 effect,在 DOM 更新之后执行。

useEffect 可看做生命周期函数 componentDidMount、componentDidUpdate 和 componentWillUnmount 的组合。

无需清除的 effect => 发送网络请求,手动变更 DOM,记录日志。
需要清除的 effect => 订阅外部数据源,监听。

useEffect Hook 的第一个参数是函数,内部注册副作用操作和监听器,返回函数用于清除监听的逻辑。useEffect Hook 的第二个参数是依赖数组,只有当所依赖的状态或 prop 改变时才会调用 useEffect Hook。

传入空数组和不传入数组的效果不同。前者是只在首次更新时执行,后续更新不执行;后者是渲染即执行。

[] => componentDidMount、componentWillUnMount
不传入 [] => componentDidMount、componentDidUpdate 和 componentWillUnmount

SOF => state 或 props 变更时,函数组件的 useEffect 会先执行 effect 的 return 所返回的函数,再执行 effect 函数体中的副作用内容。

import React, { useState, useEffect } from "react";
import { BrowserRouter as Router, Route, Link, Routes } from "react-router-dom";
function Index() {
  useEffect(() => {
    console.log("useEffect=>Indexpage");
    return () => {
      console.log("Bye, Index page");
    };
  }, []);
  return <p>indexPage</p>;
}

function OtherPage() {
  useEffect(() => {
    console.log("useEffect=>otherPage");
    return () => {
      console.log("Bye, Other page");
    };
  }, []);
  return <p>otherPage</p>;
}
function useEffectHook() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    console.log(`useEffect=>You clicked ${count} times`);
    return () => {
      console.log("DONE");
    };
  }, [count]);
  return (
    <div>
      <p>You clicked {count} times</p>
      <button
        onClick={() => {
          setCount(count + 1);
        }}
      >
        click me
      </button>
      <br />
      <Router>
        <Link to="/">indexPage</Link> & <Link to="/otherPage">otherPage</Link>
        <Routes>
          <Route path="/" exact element={<Index />} />
          <Route path="/otherPage" element={<OtherPage />} />
        </Routes>
      </Router>
    </div>
  );
}
export default useEffectHook;
  • 减少组件的更新频率 => useCallback

在函数组件中,定义在组件内部的函数会随着状态的更新而重新渲染,即函数中定义的函数会被频繁定义。在父子组件通讯的情况中,是特别消耗性能的。

useCallback 接收一个回调函数和依赖数组,返回一个 memoized 回调函数。

const memoizedCallback = useCallback(
  () => {
    doSomething(a, b);
  },
  [a, b],
);

函数组件通讯时,父组件的状态变化会导致内部的函数被重新定义,那么在将此被重新定义的函数作为传入子组件的 props 的值时,会导致子组件重新渲染。

React.memo 只能保证在相同 props 的情况下跳过渲染组件的操作并直接复用最近一次渲染的结果。

import React, { useState, useCallback } from "react";
function UseCallbackHook() {
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount(count + 1);
  };
  const subClick01 = () => {};
  const subClick02 = useCallback(() => {}, []);
  return (
    <div>
      <span>{count}</span>&nbsp;&nbsp;
      <button onClick={handleClick}>Sup Increment</button>
      <Sub01 click={subClick01} />
      <Sub02 click={subClick02} />
    </div>
  );
}
// React.memo 包裹子组件避免触发 => 不传入变化的 props 时可行
const Sub01 = React.memo(function Sub01() {
  console.log("Sub01 Component is triggered");
  return (
    <>
      <p>Hello Sub01</p>
    </>
  );
});
const Sub02 = React.memo(function Sub02() {
  console.log("Sub02 Component is triggered");
  return (
    <>
      <p>Hello Sub02</p>
    </>
  );
});
export default UseCallbackHook;
  • 类似计算属性的监听 => useMemo

useMemo is a React Hook that lets you cache the result of a calculation between re-renders.

useMemo 传入的函数内部必须有返回值;useMemo 只能声明在函数组件内部。

useMemo 接收一个具有返回值的回调函数和依赖数组,返回一个 memoized 值。

React.memo 的使用位置处于函数式组件的外围,不能直接用 useMemo 替代。

import React, { useState, useMemo } from "react";
function UseMemoHook() {
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);
  const [d, setD] = useState(0);
  const handleClick = (action) => {
    switch (action.type) {
      case "a":
        setA(a + 1);
        break;
      case "b":
        setB(b + 1);
        break;
      case "d":
        setD(d + 1);
        break;
      default:
        return false;
    }
  };
  const c = useMemo(() => {
    console.log("useMemo");
    return (
      <>
        <span>{a + b}</span>
        <span> - DOM 输出</span>
      </>
    );
  }, [a, b]);
  return (
    <>
      <p>a: {a}</p>
      <p>b: {b}</p>
      <p>c: {c}</p>
      <p>d: {d}</p>
      <button
        onClick={() => {
          handleClick({ type: "a" });
        }}
      >
        +a
      </button>
      <button
        onClick={() => {
          handleClick({ type: "b" });
        }}
      >
        +b
      </button>
      <button
        onClick={() => {
          handleClick({ type: "d" });
        }}
      >
        +d
      </button>
    </>
  );
}
export default UseMemoHook;

额外的 Hook

  • useRef => 返回 ref 对象,该对象在组件的整个生命周期中保持不变

useRef returns a mutable ref object whose .current property is initialized to the passed argument (initialValue). The returned object will persist for the full lifetime of the component.

createRef 返回的 ref 对象会随着函数组件的渲染而重新初始化,useRef 返回的 ref 对象在组件的整个生命周期内都会持续存在。

useRef() 可以方便地保存任何可变值,类似于在 class 中使用实例字段的方式。

import React, { useRef, useState, useEffect } from "react";
function UseRefHook() {
  const [renderTimes, setRenderTimes] = useState(0);
  const refByUseRef = useRef();
  let thisVal = "hello useRef";
  const refLikeThis = useRef(thisVal);
  const refByCreateRef = React.createRef();
  if (!refByUseRef.current) {
    refByUseRef.current = renderTimes;
  }
  if (!refByCreateRef.current) {
    refByCreateRef.current = renderTimes;
  }
  useEffect(() => {
    console.log(refLikeThis);
  });
  return (
    <>
      <span>renderTimes: {renderTimes}</span>&nbsp;&nbsp;
      <span>refByUseRef: {refByUseRef.current}</span>&nbsp;
      <span>refByCreateRef: {refByCreateRef.current}</span>&nbsp;&nbsp;
      <br />
      <button
        onClick={() => {
          setRenderTimes((time) => time + 1);
        }}
      >
        render now
      </button>
    </>
  );
}
export default UseRefHook;
  • useLayoutEffect => DOM 变更之后同步调用 effect

useEffect 会在渲染的内容更新到 DOM 上后执行,不会阻塞 DOM 更新。useLayoutEffect 在渲染的内容更新到 DOM 上之前执行,会阻塞 DOM 的更新,可解决使用 useEffect 所导致页面闪动的问题。

React

创建并调用函数组件 => 更新 DOM => useLayoutEffect => 渲染视图 => useEffect => 侦测到状态改变重新执行函数组件 => 和 Virtual DOM 比较后更新 DOM => 调用 useLayoutEffect => 渲染视图 => useEffect => 组件被移除 => useLayoutEffect => 调用 useEffect。

useInsertionEffect 在所有 DOM 突变之前同步触发,可将样式注入 DOM。由于其执行的时机,不能访问 refs,也不能安排更新。

import React, { useEffect, useLayoutEffect, useState } from "react";
function UseLayoutEffectHook() {
  const [state, setState] = useState(0);
  console.log("render", state);
  useEffect(() => {
    console.log("useEffect render", state);
  }, [state]);
  useLayoutEffect(() => {
    console.log("useLayoutEffect render", state);
  }, [state]);
  return (
    <>
      <button
        onClick={() => {
          setState(state + 1);
        }}
      >
        state: {state}
      </button>
    </>
  );
}
export default UseLayoutEffectHook;
  • useImperativeHandle => 使用 ref 时自定义暴露给父组件的部分功能

在 React 中,通常 useRefforwardRefuseImperativeHandle 会结合使用,以实现类似于 Vue 中直接设置子组件的 ref 属性并通过 this.$refs.xxx 访问子组件实例的功能。

在 React 中给子组件设置 ref 属性时会出现报错,这是因为在 React 中,ref 是一种特殊的属性,不能直接用于函数式组件或者普通的 HTML 元素上。只有在类组件中才可以使用 ref 属性。

React

forwardRefuseImperativeHandle 组合起来可以帮助控制子组件暴露哪些方法给父组件,从而实现方法的保护或封装。

input & focus 需求可通过 useRef 实现。若将 input & focus 再封装一层,Sup 组件也需要对这个输入框执行聚焦相关的操作时,考虑 useImperativeHandle。

useImperativeHandle 可以让父组件获取子组件的数据或者调用子组件里声明的函数。

import React, { useRef, useImperativeHandle, forwardRef } from "react";
function FancyInput(props, ref) {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    },
    defaultVal: "hello useImperativeHandle"
  }));
  return (
    <input ref={inputRef} type="text" placeholder="hello ..." {...props} />
  );
}
export default forwardRef(FancyInput);
import React, { useRef } from "react";
import FancyInput from "./FancyInput";
export default function UseImperativeHandleHook() {
  const supInputRef = useRef(null);
  console.log(supInputRef);
  const foucsFancyInput = () => {
    supInputRef.current.focus();
    console.log(supInputRef.current.defaultVal);
  };
  return (
    <>
      <h4 onClick={foucsFancyInput}>Click Me</h4>
      <FancyInput ref={supInputRef} />
    </>
  );
}
  • useDeferredValue => 返回传入值的副本
const [queryStr, setQueryStr] = useState('');
const deferredQueryStr = useDeferredValue(queryStr);

queryStr 是常规的 state,deferredQueryStr 是 queryStr 的延迟值。

设置延迟值后每次调用 setState 都会触发两次组件的重新渲染。deferredQueryStr 的值首次是 queryStr 修改前的值,第二次是修改后的值。延迟值相较于 state 总会慢一步更新。

当指定状态需要在多个组件中使用时,不同组件会有不同的渲染效率。渲染较快的组件使用正常的状态,渲染较慢的组件使用该状态的延迟值。可结合 React.memo 或 useMemo。

  • useTransition => 处理非紧急更新

默认所有更新都是具有相同优先级的阻塞渲染很可能会降低页面效率,在并发模式中,渲染不是阻塞的,而是可中断的。

返回一个状态值表示过渡任务的等待状态,以及一个启动该过渡任务的函数。

const [isPending, startTransition] = useTransition();

作为过渡任务的函数,会在其他优先级更高的方法执行完毕后运行。启动该过渡任务的函数 startTransition 可以在不需要 isPending 时直接使用。

import { useState, useTransition } from "react";
export default function App() {
  const [isPending, startTransition] = useTransition();
  const [input, setInput] = useState("");
  const [list, setList] = useState([]);
  const LIST_SIZE = 1000;
  function handleChange(e) {
    setInput(e.target.value);
    startTransition(() => {
      const l = [];
      for (let i = 0; i < LIST_SIZE; i++) {
        l.push(e.target.value);
      }
      setList(l);
    });
  }
  return (
    <>
      <input type="text" value={input} onChange={handleChange} />
      {isPending
        ? "Loading..."
        : list.map((item, index) => {
            return <div key={index}>{item}</div>;
          })}
    </>
  );
}
  • useId => 生成唯一 id,不适用于列表的 key

当封装组件在被复用时,可能存在标识符重复的异常。

每次组件渲染,useId 都会返回 unique id。这可以避免出现相同 id 所致的错误。

每次页面的渲染(多次刷新),固定位置所产生的随机 id 都会相同。

既使用 SSR,又使用 CSR 渲染部分页面时,可能会导致 ids 不匹配的情况出现 => Math.random 所产生的数据是大概率不同的。

useId 可以保证客户端渲染与服务端渲染产生一致的 id。

useId 产生的 id 不能用 querySelector 所捕获,欲拿到 DOM,可以使用 useRef。

// EmailForm.js 修改前
export default function EmailForm(){
  return (
    <>
      <label htmlFor="email">Email</label> // 给 label 添加 htmlFor 属性 => 点击 label -> Input 框自动聚焦
      <input id="email" type="email" /> // 复用多次会产生相同 id => 点击后面的输入框不会聚焦 -> 默认聚焦最前方输入框
    </>
  )
}
// EmailForm.js 修改后
import { useId, useRef } from "react";
export default function EmailForm(){
  const id = useId();
  const ref = useRef();
  return (
    <>
      <label htmlFor={`${id}-email`}>Email</label>
      <input ref={ref} id={`${id}-email`} type="email" />
      <br />
      <label htmlFor={`${id}-name`}>Name</label>
      <input ref={ref} id={`${id}-name`} type="text" />
    </>
  )
}

自定义 Hook

自定义 Hook 用作共享组件之间的状态逻辑,是 props render 和 HOC 的替代。

自定义 Hook 是一个函数,其名称以 use 开头,函数内部可以调用其他的 Hook。

Hook 注意事项

Sup Func Component 会因 setState 导致其内部的 Sub Func Component 被重新渲染。通常使用 memo 包裹 Sub Func Component 以达到类组件中 pureComponent 的效果。

React.memo 高阶组件仅通过检查 props 的变更以判断是否直接复用最近一次渲染的结果。被 React.memo 所包裹的 Func 组件的实现中拥有 useState,useReducer 或 useContext 的 Hook 时,state 或 context 变化依旧会导致组件的重新渲染。

React Router

Router Principle

接受 props:一般组件根据标签中所传递的内容确认 props,路由组件默认接受被路由器固定传递的三个属性。

history => action、block、createHref、go、goBack、goForward、length、listen、location、push、replace
location => hash、key、pathname、search、state
match => isExact、params、path、url
  • 严格模式开启时,可能会导致该级路由下的所有子路由无法正常匹配

注册嵌套路由时要写上前一级路由的路径,路由的匹配是按照注册路由的顺序进行的。注册路由即展示区的 <Route path="" component={}>

// Sup 组件开启严格模式
<Route exact path="/sup" component={Sup}/>
// Sub 组件无法正常匹配
<NavLink to="/sup/sub"> Sub </NavLink>
<Route path="/sup/sub" component={Sub}/>

向路由组件传参

  • params 传参 => 路由链接携带的参数需在注册路由时接收
// 传递参数
<Link to={`/a/b/c/${xxx.id}/${xxx.name}`}>hello</Link>
// 声明接受
<Route path="/a/b/c/:id/:name" component={Hello}/>
// 组件获取
const {id, name} = this.props.match.params
  • search 传参 => 无需声明接收,类似 Ajax 的 query 参数

获取到的 search 是 urlencoded 编码字符串,需借助 querystring 解析。

// 传递参数
<Link to={`/a/b/c/?id=${xxx.id}&name=${xxx.name}`}>hello</Link>
// 声明接受
<Route path="/a/b/c" component={Hello}/>
// 组件获取
import qs from 'querystring'
const {search} = this.props.location
const {id, name} = qs.parse(search.slice(1))
  • state 传参 => 传递的数据在地址栏隐藏,且无需声明接收

在 Vue 中使用 params 参数且未在路由配置里指明时,会出现刷新所导致的参数丢失问题。React 路由中的 state 传参虽没有显示在地址栏,但不会出现此类情况。

react-router-dom 中的 BrowserRouter 维护浏览器的 history,location 是 history 中的一个属性。考虑到会有清除浏览器缓存并刷新的情况,故添加空对象以处理不存在 state 的问题。state 默认为 undefined。

// 传递参数
<Link to={{pathname:"/a/b/c", state:{id:xxx.id, name:xxx.name}}}>hello</Link>
// 声明接受
<Route path="/a/b/c" component={Hello}/>
// 组件获取
const {id, name} = this.props.location.state || {}
  • BrowserRouter 和 HashRouter

底层原理:前者使用 H5 的 history,不兼容 IE9- 版本,后者使用 URL 哈希值。
路径表现:前者无 #,后者包含 #。
刷新对路由 state 参数的影响:前者无影响,state 存储于 history;后者 state 会因刷新而丢失。

编程式路由导航

业务场景有时需要点击按钮进行页面的跳转,此时就应该从声明式路由导航转向使用编程式路由导航。考虑到需要传递相关参数,事件处理函数可以使用高阶函数或进行函数包裹。

编程式路由导航主要使用的是 history 对象,即 React Router 的 history 依赖包。

push(path, [state]) - (function 类型) 在 history 堆栈添加一个新条目
replace(path, [state]) - (function 类型) 替换在 history 堆栈中的当前条目

  • params 参数 & 编程式路由导航
<button onClick={() => this.pushHandle(xxx.id, xxx.name)}>push</buttton>
<button onClick={() => this.replaceHandle(xxx.id, xxx.name)}>replace</buttton>
pushHandle = (id, name) => {this.props.history.push(`/a/b/c/${id}/${name}`)}
replaceHandle = (id, name) => {this.props.history.replace(`/a/b/c/${id}/${name}`)}
<Route path="/a/b/c/:id/:name" component={Xxx}/>
  • search 参数 & 编程式路由导航
<button onClick={() => this.pushHandle(xxx.id, xxx.name)}>push</buttton>
<button onClick={() => this.replaceHandle(xxx.id, xxx.name)}>replace</buttton>
pushHandle = (id, name) => {this.props.history.push(`/a/b/c/?id=${id}&name=${name}`)}
replaceHandle = (id, name) => {this.props.history.replace(`/a/b/c/?id=${id}&name=${name}`)}
<Route path="/a/b/c" component={Xxx}/>
import qs from 'querystring'
const {search} = this.props.location
const {id, name} = qs.parse(search.slice(1))
  • state 参数 & 编程式路由导航
<button onClick={() => this.pushHandle(xxx.id, xxx.name)}>push</buttton>
<button onClick={() => this.replaceHandle(xxx.id, xxx.name)}>replace</buttton>
pushHandle = (id, name) => {this.props.history.push(`/a/b/c/`, {id, name})}
replaceHandle = (id, name) => {this.props.history.replace(`/a/b/c/`, {id, name})}
<Route path="/a/b/c" component={Xxx}>
const {id, name} = this.props.location.state || {}
  • withRouter 函数 => 使一般组件获得路由组件的相关 API

可通过 withRouter 高阶组件访问 history 对象的属性和最近 <Route> 的 match。
当路由渲染时,withRouter 会将已经更新的 match,location 和 history 属性传递给被包裹的组件。

// withRouter => 使一般组件具有路由组件 API 
import React, {Component} from 'react'
import {withRouter} from 'react-router-dom'
class Demo extends Component{...}
export default withRouter(Demo)

React Router v6

  • React Router 以三个不同的包发布到 npm

react-router => 路由核心库,提供组件和钩子;react-router-dom => react-router 所有内容都被包含,添加专门用于 DOM 的组件,如 <BrowserRouter> 等。react-router-native => 含 react-router 所有内容,额外增加用于 ReactNative 的 API,如 <NativeRouter> 等。

  • <Routes/> 与 <Route/>

通过引入 <Routes> 替代移除的 <Switch><Routes><Route> 应配合使用,前者将后者包裹。

path 属性用于定义路径,element 属性用于定义当前路径所对应的组件。

<Route> 相当于 if 语句,若其路径与当前 URL 匹配,则呈现对应的组件。

<Route> 中的 caseSensitive 属性用于指定匹配时是否区分大小写,默认 false。

当 URL 改变时,<Routes> 会查看所有子 <Route> 以匹配并呈现组件。

<Route> 可嵌套使用,可配合 useRoutes() 配置路由表,但需 <Outlet> 组件以渲染其子路由。

<Routes>
  // 一级路由 demo 所对应的路径为 /demo*/
  <Route path="demo" element={<Demo/>}>
    // inner01 和 inner02 是二级路由 => 对应路径为 /demo/inner01 和 /demo/inner02
    <Route path="inner01" element={<Inner01/>}></Route>
    <Route path="inner02" element={<Inner02/>}></Route>
  </Route>
</Routes>
  • React Router6 实现自定义的类名时,需要把 className 的值写成函数
// React Router5
<NavLink className="default-style" activeClassName="additional-style" to="/demo">Demo</NavLink>
// React Router6
<NavLink className={({isActive})=>{return isActive ? "default-style additional-style" : "default-style"}} to="/demo">Demo</NavLink>
  • useRoutes => 将应用路由的层级进行统一管理
import React from 'react'
import {NavLink, useRoutes} from 'react-router-dom'
import routes from './routes'
export default function App(){
  const element = useRoutes(routes)
  return (
    ...
    {element}
  )
}
// routes/index.js
import Dashboard from '../pages/Dashboard'
import Info from '..pages/Info'
import {Navigate} from 'react-router-dom'
export default [
  {path: "/dashboard", element:<Dashboard/>},
  {path: "/info", element:<Info/>},
  {path: "/", element:<Navigate to="/dashboard"/>}
]

NavLink

If the end prop is used, it will ensure this component isn't matched as "active" when its descendant paths are matched. v6.3

  • this.props.match.params 通过 useParams 或 useMatch 替代

  • this.props.location 的 search 参数通过 useSearchParams 或 useLocation 替代

import {useSearchParams} from 'react-router-dom'
export default function Xxx(){
  const [search, setSearch] = useSearchParams();
  const id = search.get("id");
  const msg = search.get("msg");
  ...
  return (<>...</>)
}
  • state 参数使用 useLocation 替代

  • 编程式路由导航

  • v6 中没有 this,可使用 useNavigate

useNavigate hook 返回一个以编程方式导航的函数,例如在提交表单之后。

declare function useNavigate(): NavigateFunction;
interface NavigateFunction {
  (
    to: To,
    options?: { replace?: boolean; state?: any }
  ): void;
  (delta: number): void;
}

withRouter 函数在 v6 中被 useNavigate 替代,传入 delta 以对 history 进行操作。

import { useNavigate } from "react-router-dom";
function eventHandle() {
  let navigate = useNavigate();
  async function handleSubmit(event) {
    event.preventDefault();
    await submitForm(event.target);
    navigate("../success", { replace: true });
  }
  return <form onSubmit={handleSubmit}>{/* ... */}</form>;
}
  • useInRouterContext => 判断组件是否在 <Router> 上下文呈现

通过调用该 hook 可以判断当前组件是否被 <XxxRouter> 之流包裹。开发中若直接将 <App> 组件以路由包裹,则

封装组件时判断使用者是否在路由环境下使用。

  • useNavigationType => 返回当前的导航类型 -> POP|PUSH|REPLACE

来到当前页面的跳转方式。POP 为直接在浏览器打开该路由组件(刷新页面)。

  • useOutlet => 呈现当前组件中要渲染的嵌套路由

嵌套路由挂载则展示嵌套的路由对象,没有挂载则返回 null。

const result = useOutlet(); // 嵌套路由没有挂载则返回 null
  • useResolvedPath => 给定 URL 值,解析其中的 path|search|hash

  • <Navigate>

<Navigate> 组件在渲染时会修改路径并切换视图。replace 属性用于控制跳转模式,可选 push 或 replace,默认为 push。

import React, {useState} from 'react'
import {Navigate} from 'react-router-dom'
export default function Demo(){
  const [sum, setSum] = useState(1)
  return (
    <>
      {sum === 1 ? <h4>sum 的值为 {sum}</h4>:<Navigate to="/about" replace/>}
      <button onClick={() => setSum(2)}>Click Change Sum to 2</button>
    </>
  )
}
  • <Outlet> => 当 <Route> 产生嵌套时,渲染其对应的后续子路由
const element = useRoutes([
  {path:'/about', element:<About/>},
  {
    path:'/info',
    element:<Info/>,
    children:[
      {path:'news', element:<News/>},
      {path:'msg', element:<Msg/>}
    ]
  }
])
import React from 'react'
import {NavLink, Outlet} from 'react-router-dom'
export default function Home(){
  return (
    <>
      <h2>Home...</h2>
      <div>
        <ul className="nav nav-tabs">
          <li><NavLink className="list-group-item" to="news">News</NavLink></li>
          <li><NavLink className="list-group-item" to="msg">Msg</NavLink></li>
        </ul>
        <Outlet/>
      </div>
    </>
  )
}
  • useRoutes => 根据路由表动态创建 <Routes> 和 <Route>

Parsing The Source Code

v18 事件系统

  • 事件注册

DOMPluginEventSystem 中调用各类 XxxEventPlugin 的 registerEvents() 注册事件。registerEvents 即从 DOMEventProperties 导入的 registerSimpleEvents 函数。如 SimpleEventPluginregisterSimpleEvents

// registerSimpleEvents 即 registerEvents
export function registerSimpleEvents() {
  for (let i = 0; i < simpleEventPluginEvents.length; i++) {
    const eventName = ((simpleEventPluginEvents[i]: any): string);
    const domEventName = ((eventName.toLowerCase(): any): DOMEventName);
    const capitalizedEvent = eventName[0].toUpperCase() + eventName.slice(1);
    registerSimpleEvent(domEventName, 'on' + capitalizedEvent);
  }
  // Special cases where event names don't match.
  registerSimpleEvent(ANIMATION_END, 'onAnimationEnd');
  registerSimpleEvent(ANIMATION_ITERATION, 'onAnimationIteration');
  registerSimpleEvent(ANIMATION_START, 'onAnimationStart');
  registerSimpleEvent('dblclick', 'onDoubleClick');
  registerSimpleEvent('focusin', 'onFocus');
  registerSimpleEvent('focusout', 'onBlur');
  registerSimpleEvent(TRANSITION_END, 'onTransitionEnd');
}

registerSimpleEvents() 内有调用 registerSimpleEvent() 函数,后者内部又调用 registerTwoPhaseEvent() 以分别注册捕获和冒泡阶段的事件。

function registerSimpleEvent(domEventName, reactName) {
  topLevelEventsToReactNames.set(domEventName, reactName);
  registerTwoPhaseEvent(reactName, [domEventName]);
}
export function registerTwoPhaseEvent(
  registrationName: string,
  dependencies: Array<DOMEventName>,
): void {
  registerDirectEvent(registrationName, dependencies);
  registerDirectEvent(registrationName + 'Capture', dependencies);
}
export function registerDirectEvent(
  registrationName: string,
  dependencies: Array<DOMEventName>,
) {
  ...
  registrationNameDependencies[registrationName] = dependencies;
}
  • 事件绑定

createRootReactDOM.jsclient.js 中视作 createRootImpl 进行调用,即在创建根节点之后会执行 listenToAllSupportedEvents。

export function createRoot(
  container: Element | Document | DocumentFragment,
  options?: CreateRootOptions,
): RootType {
  ...
  listenToAllSupportedEvents(rootContainerElement);
}
export function listenToAllSupportedEvents(rootContainerElement: EventTarget) {
  if (!(rootContainerElement: any)[listeningMarker]) {
    (rootContainerElement: any)[listeningMarker] = true;
    allNativeEvents.forEach(domEventName => {
      // We handle selectionchange separately because it
      // doesn't bubble and needs to be on the document.
      if (domEventName !== 'selectionchange') {
        if (!nonDelegatedEvents.has(domEventName)) {
          listenToNativeEvent(domEventName, false, rootContainerElement);
        }
        listenToNativeEvent(domEventName, true, rootContainerElement);
      }
    });
    const ownerDocument =
      (rootContainerElement: any).nodeType === DOCUMENT_NODE
        ? rootContainerElement
        : (rootContainerElement: any).ownerDocument;
    if (ownerDocument !== null) {
      // The selectionchange event also needs deduplication
      // but it is attached to the document.
      if (!(ownerDocument: any)[listeningMarker]) {
        (ownerDocument: any)[listeningMarker] = true;
        listenToNativeEvent('selectionchange', false, ownerDocument);
      }
    }
  }
}

调用 addTrappedEventListener 进行真正的事件绑定,绑定在document上,dispatchEvent 为统一的事件处理函数。

export function listenToNativeEvent(
  domEventName: DOMEventName,
  isCapturePhaseListener: boolean,
  target: EventTarget,
): void {
  if (__DEV__) {
    if (nonDelegatedEvents.has(domEventName) && !isCapturePhaseListener) {
      console.error(
        'Did not expect a listenToNativeEvent() call for "%s" in the bubble phase. ' +
          'This is a bug in React. Please file an issue.',
        domEventName,
      );
    }
  }

  let eventSystemFlags = 0;
  if (isCapturePhaseListener) {
    eventSystemFlags |= IS_CAPTURE_PHASE;
  }
  addTrappedEventListener(
    target,
    domEventName,
    eventSystemFlags,
    isCapturePhaseListener,
  );
}
function addTrappedEventListener(
  targetContainer: EventTarget,
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  isCapturePhaseListener: boolean,
  isDeferredListenerForLegacyFBSupport?: boolean,
) {
  let listener = createEventListenerWrapperWithPriority( // 创建具有优先级的监听函数
    targetContainer,
    domEventName,
    eventSystemFlags,
  );
  ...
  targetContainer =
    enableLegacyFBSupport && isDeferredListenerForLegacyFBSupport
      ? (targetContainer: any).ownerDocument
      : targetContainer;
  let unsubscribeListener;
  if (enableLegacyFBSupport && isDeferredListenerForLegacyFBSupport) {
    const originalListener = listener;
    listener = function(...p) {
      removeEventListener(
        targetContainer,
        domEventName,
        unsubscribeListener,
        isCapturePhaseListener,
      );
      return originalListener.apply(this, p);
    };
  }
  // TODO: There are too many combinations here. Consolidate them.
  if (isCapturePhaseListener) { // 事件捕获阶段处理函数 => 节点上添加事件
    if (isPassiveListener !== undefined) {
      unsubscribeListener = addEventCaptureListenerWithPassiveFlag(
        targetContainer,
        domEventName,
        listener,
        isPassiveListener,
      );
    } else {
      unsubscribeListener = addEventCaptureListener(
        targetContainer,
        domEventName,
        listener,
      );
    }
  } else {
    if (isPassiveListener !== undefined) {
      unsubscribeListener = addEventBubbleListenerWithPassiveFlag(
        targetContainer,
        domEventName,
        listener,
        isPassiveListener,
      );
    } else {
      unsubscribeListener = addEventBubbleListener(
        targetContainer,
        domEventName,
        listener,
      );
    }
  }
}
export function createEventListenerWrapperWithPriority(
  targetContainer: EventTarget,
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
): Function {
  const eventPriority = getEventPriority(domEventName);
  let listenerWrapper;
  switch (eventPriority) {
    case DiscreteEventPriority:
      listenerWrapper = dispatchDiscreteEvent;
      break;
    case ContinuousEventPriority:
      listenerWrapper = dispatchContinuousEvent;
      break;
    case DefaultEventPriority:
    default:
      listenerWrapper = dispatchEvent;
      break;
  }
  return listenerWrapper.bind( // 绑定 dispatchDiscreteEvent
    null,
    domEventName,
    eventSystemFlags,
    targetContainer,
  );
}
  • 事件触发

React 事件注册时,dispatchDiscreteEvent 为统一的事件处理函数,即触发事件首先执行 dispatchDiscreteEvent 函数,因 dispatchDiscreteEvent 前三个参数已经被 bind 绑定,故事件源对象 event.target 被默认绑定成最后参数 nativeEvent。

function dispatchDiscreteEvent(
  domEventName,
  eventSystemFlags,
  container,
  nativeEvent,
) {
  const previousPriority = getCurrentUpdatePriority();
  const prevTransition = ReactCurrentBatchConfig.transition;
  ReactCurrentBatchConfig.transition = null;
  try {
    setCurrentUpdatePriority(DiscreteEventPriority);
    dispatchEvent(domEventName, eventSystemFlags, container, nativeEvent);
  } finally {
    setCurrentUpdatePriority(previousPriority);
    ReactCurrentBatchConfig.transition = prevTransition;
  }
}
export function dispatchEvent(
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
  nativeEvent: AnyNativeEvent,
): void {
  if (!_enabled) {
    return;
  }
  if (enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay) {
    dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay(
      domEventName,
      eventSystemFlags,
      targetContainer,
      nativeEvent,
    );
  } else {
    dispatchEventOriginal(
      domEventName,
      eventSystemFlags,
      targetContainer,
      nativeEvent,
    );
  }
}
import {dispatchEventForPluginEventSystem} from './DOMPluginEventSystem';
...
function dispatchEventOriginal(
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
  nativeEvent: AnyNativeEvent,
) {
  // TODO: replaying capture phase events is currently broken
  // because we used to do it during top-level native bubble handlers
  // but now we use different bubble and capture handlers.
  // In eager mode, we attach capture listeners early, so we need
  // to filter them out until we fix the logic to handle them correctly.
  const allowReplay = (eventSystemFlags & IS_CAPTURE_PHASE) === 0;

  if (
    allowReplay &&
    hasQueuedDiscreteEvents() &&
    isDiscreteEventThatRequiresHydration(domEventName)
  ) {
    // If we already have a queue of discrete events, and this is another discrete
    // event, then we can't dispatch it regardless of its target, since they
    // need to dispatch in order.
    queueDiscreteEvent(
      null, // Flags that we're not actually blocked on anything as far as we know.
      domEventName,
      eventSystemFlags,
      targetContainer,
      nativeEvent,
    );
    return;
  }

  const blockedOn = findInstanceBlockingEvent(
    domEventName,
    eventSystemFlags,
    targetContainer,
    nativeEvent,
  );
  if (blockedOn === null) {
    dispatchEventForPluginEventSystem(
      domEventName,
      eventSystemFlags,
      nativeEvent,
      return_targetInst,
      targetContainer,
    );
    if (allowReplay) {
      clearIfContinuousEvent(domEventName, nativeEvent);
    }
    return;
  }

  if (allowReplay) {
    if (isDiscreteEventThatRequiresHydration(domEventName)) {
      // This this to be replayed later once the target is available.
      queueDiscreteEvent(
        blockedOn,
        domEventName,
        eventSystemFlags,
        targetContainer,
        nativeEvent,
      );
      return;
    }
    if (
      queueIfContinuousEvent(
        blockedOn,
        domEventName,
        eventSystemFlags,
        targetContainer,
        nativeEvent,
      )
    ) {
      return;
    }
    // We need to clear only if we didn't queue because
    // queueing is accumulative.
    clearIfContinuousEvent(domEventName, nativeEvent);
  }

  // This is not replayable so we'll invoke it but without a target,
  // in case the event system needs to trace it.
  dispatchEventForPluginEventSystem(
    domEventName,
    eventSystemFlags,
    nativeEvent,
    null,
    targetContainer,
  );
}

dispatchEventsForPlugins

HostComponent 常量为 5。

import { HostRoot, HostPortal, HostComponent, HostText, ScopeComponent, } from 'react-reconciler/src/ReactWorkTags';
...
export function dispatchEventForPluginEventSystem(
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  nativeEvent: AnyNativeEvent,
  targetInst: null | Fiber,
  targetContainer: EventTarget,
): void {
  let ancestorInst = targetInst;
  if (
    (eventSystemFlags & IS_EVENT_HANDLE_NON_MANAGED_NODE) === 0 &&
    (eventSystemFlags & IS_NON_DELEGATED) === 0
  ) {
    const targetContainerNode = ((targetContainer: any): Node);

    // If we are using the legacy FB support flag, we
    // defer the event to the null with a one
    // time event listener so we can defer the event.
    if (
      enableLegacyFBSupport &&
      // If our event flags match the required flags for entering
      // FB legacy mode and we are processing the "click" event,
      // then we can defer the event to the "document", to allow
      // for legacy FB support, where the expected behavior was to
      // match React < 16 behavior of delegated clicks to the doc.
      domEventName === 'click' &&
      (eventSystemFlags & SHOULD_NOT_DEFER_CLICK_FOR_FB_SUPPORT_MODE) === 0 &&
      !isReplayingEvent(nativeEvent)
    ) {
      deferClickToDocumentForLegacyFBSupport(domEventName, targetContainer);
      return;
    }
    if (targetInst !== null) {
      // The below logic attempts to work out if we need to change
      // the target fiber to a different ancestor. We had similar logic
      // in the legacy event system, except the big difference between
      // systems is that the modern event system now has an event listener
      // attached to each React Root and React Portal Root. Together,
      // the DOM nodes representing these roots are the "rootContainer".
      // To figure out which ancestor instance we should use, we traverse
      // up the fiber tree from the target instance and attempt to find
      // root boundaries that match that of our current "rootContainer".
      // If we find that "rootContainer", we find the parent fiber
      // sub-tree for that root and make that our ancestor instance.
      let node = targetInst;

      mainLoop: while (true) {
        if (node === null) {
          return;
        }
        const nodeTag = node.tag;
        if (nodeTag === HostRoot || nodeTag === HostPortal) {
          let container = node.stateNode.containerInfo;
          if (isMatchingRootContainer(container, targetContainerNode)) {
            break;
          }
          if (nodeTag === HostPortal) {
            // The target is a portal, but it's not the rootContainer we're looking for.
            // Normally portals handle their own events all the way down to the root.
            // So we should be able to stop now. However, we don't know if this portal
            // was part of *our* root.
            let grandNode = node.return;
            while (grandNode !== null) {
              const grandTag = grandNode.tag;
              if (grandTag === HostRoot || grandTag === HostPortal) {
                const grandContainer = grandNode.stateNode.containerInfo;
                if (
                  isMatchingRootContainer(grandContainer, targetContainerNode)
                ) {
                  // This is the rootContainer we're looking for and we found it as
                  // a parent of the Portal. That means we can ignore it because the
                  // Portal will bubble through to us.
                  return;
                }
              }
              grandNode = grandNode.return;
            }
          }
          // Now we need to find it's corresponding host fiber in the other
          // tree. To do this we can use getClosestInstanceFromNode, but we
          // need to validate that the fiber is a host instance, otherwise
          // we need to traverse up through the DOM till we find the correct
          // node that is from the other tree.
          while (container !== null) {
            const parentNode = getClosestInstanceFromNode(container);
            if (parentNode === null) {
              return;
            }
            const parentTag = parentNode.tag;
            if (parentTag === HostComponent || parentTag === HostText) {
              node = ancestorInst = parentNode;
              continue mainLoop;
            }
            container = container.parentNode;
          }
        }
        node = node.return;
      }
    }
  }

  batchedUpdates(() =>
    dispatchEventsForPlugins(
      domEventName,
      eventSystemFlags,
      nativeEvent,
      ancestorInst,
      targetContainer,
    ),
  );
}

dispatchEventsForPlugins()extractEvents() 生成 SyntheticEvent 合成事件,而 processDispatchQueue() 执行事件队列。

function dispatchEventsForPlugins(
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  nativeEvent: AnyNativeEvent,
  targetInst: null | Fiber,
  targetContainer: EventTarget,
): void {
  const nativeEventTarget = getEventTarget(nativeEvent);
  const dispatchQueue: DispatchQueue = [];
  extractEvents(
    dispatchQueue,
    domEventName,
    targetInst,
    nativeEvent,
    nativeEventTarget,
    eventSystemFlags,
    targetContainer,
  );
  processDispatchQueue(dispatchQueue, eventSystemFlags);
}

FAQ & Bugs

React Fiber

Fiber 是 React 16 中新的协调引擎 => 使 Virtual DOM 可以进行增量式渲染。

incremental rendering 增量式渲染能够将渲染工作分块并将其分散到多个帧上。

测试一下测试一下测试一下测试一下测试一下测试一下测试一下测试一下测试一下测试一下测试一下

页面元素较多,且频繁刷新的情况下,v15 会出现掉帧的现象。由于采用的是全量渲染,渲染过程不可中断。

协调器 reconciler 会调用组件的 render 决定是否进行挂载,更新或是卸载操作。

从 v15 到 v16,React 团队花了两年时间将源码架构中的 Stack Reconciler 重构为 Fiber Reconciler。

页面节点多,层次深会导致递归渲染的耗时增加,由于 UI 线程与单线程的 JS 线程互斥,影响响应。

Fiber 其实是一种数据结构,可以用纯 JS 对象表示。fiber 也是一个执行单元,每次执行完一个执行单元,React 就会检查还剩多少时间,若没有时间就将控制权让出去。

Fiber 四个关键特性 => 增量渲染;暂停、中止、复用渲染任务;优先级更新;并发能力

  • 帧 & JS 阻塞渲染

主流刷新频率为 60HZ,即 60 帧每秒。每帧中都会包括样式计算、布局和绘制。

Chrome has a multi-process architecture and each process is heavily multi-threaded.

主线程用于浏览器处理用户事件和页面绘制等。默认情况下,浏览器在一个线程中运行一个页面中的所有 JavaScript 脚本,以及呈现布局,回流,和垃圾回收。

Performance insights 面板开启录制后分析:Main 栏中的灰色块 Run Task 是主线程中执行的任务,绿色块 First Contentful Paint 表示首次绘制。

在首次绘制之前会有 Parse HTML 和 Evaluate Script,而后者这类阻塞 DOM Tree 生成的 Script 会延长 Parse HTML 的耗时。

随后是紫色块:输出 styleSheets 的 Recalculate Style 和用作布局的 Layout。

最后是 Details 中查看到的 Composite Layers。

具体的绘制操作会将 Composite Layers 交给合成线程 Compositor。

合成线程并不会与主线程互斥。

Script 的执行和 Paint 图层的绘制都发生在主线程 Main。

渲染被阻塞的原因是由于 JS 执时间过长,导致这一帧没有时间执行 Paint 任务。

  • Fiber 执行流程

React 中可通过 this.setState、this.forceUpdate、ReactDOM.render 等 API 触发更新。更新发生时,Reconciler 会调用组件的 render 方法,将返回的 JSX 转化为虚拟 DOM;将虚拟 DOM 和上次更新时的虚拟 DOM 对比;通过对比找出本次更新中变化的虚拟 DOM;通知 Renderer 将变化的虚拟 DOM 渲染到页面上

Stack reconciler 是 v15 及更早的解决方案。Fiber 从 v16 开始成为默认的 reconciler。

React15 架构可以分为 Reconciler 协调器和 Renderer 渲染器两层。

Reconciler 协调器 => 负责找出变化的组件;
Renderer 渲染器 => 根据 Reconciler 为虚拟 DOM 打的标记,同步执行对应的 DOM 操作
在React15及以前,Reconciler采用递归的方式创建虚拟DOM,递归过程是不能中断的。如果组件树的层级很深,递归会占用线程很多时间,递归更新时间超过了16ms,用户交互就会卡顿。

为了解决这个问题,React16将递归的无法中断的更新重构为异步的可中断更新,由于曾经用于递归的虚拟DOM数据结构已经无法满足需要。于是,全新的Fiber架构应运而生。

每次渲染有两个阶段:Reconciliation(协调render阶段〕和Commit(提交阶段〕

协调的阶段:可以认为是Diff阶段,这个阶段可以被终止,这个阶段会找出所有节点变更,例如节点新增、删除、属性变更等等,这此变更React称之为副作用。

提交阶段:将上一阶段计算出来的需要处理的副作用(effects)一次性执行了。这个阶段必须同步执行,不能被打断。

故障解除

  • React 使用 antd-mobile 开发移动端项目时,在部分界面用嵌套路由(不同页面渲染内容 + TavBar恒固定)时出现点击无效的情况

经检测是 antd-mobile 中 TabBar 组件带有一个检测缩放满屏属性 fullScreen 的盒子将 TabBar 元素包裹。即如下代码:

<div style="position: fixed; height: 100%; width: 100%; top: 0px;">
 ...
</div>

这里看到一个解决方案是设置 z-index:-1 。当然在 JSX 行内元素设置务必根据驼峰写法来设置成 zIndex:-1 ,但是经本人实践,次方法会将 TabBar 直接隐藏,故不可取。

实际这一处的代码只是为了演示根据点击标签而更改 TabBar 的放置位置,若不需要还是直接去除这个包裹容器即可。

  • 校验报错 => Typo in static class property declaration react/no-typos

检查大小写 => 组件.propTypes = {} 不要写成 组件.PropTypes = {},前者的 propTypes 是 React.Component 的特殊属性。

  • CSS Modules

在配置路由时,组件都被导入到项目中,那么组件的样式也就被导入到项目中了。如果组件之间样式名称相同,那么一个组件中的样式就会在另一个组件中也生效,从而造成组件之间样式相互覆盖的问题。

CSS 仅是网页样式的描述方法。Less、SASS 到 PostCSS 都是为了让 CSS 更像一门编程语言,这也导致使用者增加更多的学习成本。是否存在一种规则少,又保证某个组件的样式不会影响到其他组件的方法—— CSS Modules 通过只加入了局部作用域和模块依赖解决组件样式冲突。

React 项目在用 npx create-react-app my-app 创建后需要使用 CSS Modules 需保证项目存在 css-loader 插件。这里解释一下为什么需要 css-loader 插件。webpack 是用 JavaScript 编写,运行在 Node 环境里的打包工具,所以默认 webpack 打包的时候只会处理JS之间的依赖关系。如果在 .js 文件中导入了 css,那么就需要使用 css-loader 识别这个导入的 css 模块,通过特定的语法规则进行转换内容最后导出这个模块数组。因为是个页面无法直接识别的数组,这时就需要用到另外一个插件 style-loader 来创建一个style标签去包含处理这些样式。否则会出现报错:

index.module.css (./node_modules/css-loader/dist/cjs.js??ref--5-oneOf-5-1!./node_modules/postcss-loader/src??postcss!. ... not found babel-loader

确保上述依赖完成后即可使用 CSS Modules 。由于 React 已内置 CSS Modules ,只需把要保证独立样式的样式提出再注入保证规范名的样式文件( [name].module.css)即可,最后 .ts 文件中通过自定义对象名引入则可以拿到经 CSS Modules 演化后生成的 css 对象。

  • 百度地图 BMapGL 未定义

React 项目中,使用百度地图 API 在位于 BMapGL 命名空间下的 Map 类通过 new 操作符创建地图实例时,出现了 'BMapGL' is not defined no-undef 的报错。

这里是因为 React 的生命周期中 render() 阶段负责创建虚 DOM,进行 diff 算法,更新 DOM树。而 render 及之前的阶段,并没有将组件渲染为实际的 DOM 节点,所以不能获取 window 对象。

这种情况下可以通过在组件外,进行声明拿到 window 对象下的 BMapGL (推荐),解决脚手架中全局变量访问的问题。再在 componentDidMount 生命周期中通过 new 方法获取实例。

// 方法一
const BMapGL = window.BMapGL
// 方法二
var map = new window.BMapGL.Map("container");
//创建地址解析器实例
var myGeo = new window.BMapGL.Geocoder();
...
  • TypeError: Class extends value undefined is not a constructor or null
src/components/demo.js:2
  1 | import React from 'react'
> 2 | export default class Demo extends React.component{ // 别写成小写
  3 |   showData = () => {
  4 |     const {inputt} = this
  5 |     alert(inputt.value)
  • Functions are not valid as a React child. This may happen if you return a Component instead of from render.

react-router-v6 => 标签 Route 的属性 component 替换为 element,element 的属性值要写成 JSX 组件的形式。SOF

<Route path="/movies/list" exact element={ MoviesList } />
  • Type '{ ref: RefObject<ChildHandle>; }' is not assignable to type 'IntrinsicAttributes'. Property 'ref' does not exist on type 'IntrinsicAttributes'.ts(2322)

错误原因:在 JSX 中,ref 属性是一个特殊的属性,不能直接通过对象字面量传递给组件。要在 JSX 中传递 ref 属性,必须使用 React.forwardRef 函数将组件包装起来。

import React, { useRef, useImperativeHandle, forwardRef } from 'react';
interface ChildProps {}
interface ChildHandle { focusInput: () => void; }
const ChildComponent = forwardRef<ChildHandle, ChildProps>((props, ref) => {
  const inputRef = useRef<HTMLInputElement>(null);
  // 定义子组件的方法,可以通过 ref 被父组件调用
  const focusInput = () => {
    if (inputRef.current) {
      inputRef.current.focus();
    }
  };
  // 使用 useImperativeHandle 暴露给父组件的方法和属性
  useImperativeHandle(ref, () => ({
    focusInput
  }));
  return (
    <input ref={inputRef} type="text" />
  );
});
const ParentComponent = () => {
  const childRef = useRef<ChildHandle>(null);
  const handleButtonClick = () => {
    if (childRef.current) {
      childRef.current.focusInput();
    }
  };
  return (
    <div>
      <ChildComponent ref={childRef} />
      <button onClick={handleButtonClick}>Focus Input</button>
    </div>
  );
};
export default ParentComponent;
Build your own React
We are going to rewrite React from scratch. Step by step. Following the architecture from the real React code but without all the…
React

结束

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!

]]>
<![CDATA[Cross-Origin And Cross-Site]]> JSONP—CORS—Proxy]]>https://zairesinatra.github.io//crossorigin/610c4d8d9d12cb5936240827Sun, 14 Feb 2021 13:47:00 GMT

同源策略

Cross-Origin And Cross-Site

浏览器安全的基石是 "同源政策"(same-origin policy)。 Netscape 公司引入浏览器。目前,所有浏览器都实行这个政策。

所谓同源是指 "协议+域名+端口" 三者相同,即便两个不同的域名指向同一个 IP 地址,也非同源。
它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到 XSS、CSFR 等攻击。

同源策略限制以下几种行为:(1) Cookie、LocalStorage 和 IndexDB 无法读取。(2) DOM 无法获得。(3) AJAX 请求不能发送。

在服务器端(项目上线部署完成)不存在跨域问题,跨域是针对浏览器的协议、域名、端口而言的。

JSONP JSON with padding

浏览器同源策略导致网页中无法通过 Ajax 请求进行跨域访问,JSONP 的原理是部分标签的 src 属性不存在跨域请求的限制,可以访问到跨域的脚本。

服务端不返回字符串格式的数据,而是返回注入数据函数调用的脚本。客户端定义服务端返回调用的同名函数。

src 属性不仅用作将函数名带入请求的参数,而且会执行服务端返回的函数调用。

const script = document.createElement("script"), handle = function (data) { console.log(data.code, data.msg); }
script.src = "http://localhost:3000/jsonp-server?callback=handle";
document.body.appendChild(script)
const express = require('express');
const app = express(), port = 3000;
app.get('/jsonp-server', (req,res)=>{
  const { callback } = req.query, data = { code: 200, msg: 'jsonp request success!' }
  res.send(`${callback}(${JSON.stringify(data)})`) // 也可以使用 end => 不加特殊响应头
});
app.listen(port, () => { console.log(`app listen is running at http://localhost:${port}`); });

类似的在 React 中,组件通讯的属性传递一般只能父传子,要是想实现子传父,可以通过父组件提供回调函数来接收数据,将该函数作为属性的值传递给子组件,子组件以 props 调用回调函数。

由于所有的 src 都是资源文件请求,导致 JSONP 只能使用 GET 方法;在路径传参将函数传递给服务器时,会存在 url 劫持的问题。

jQuery 发起 JSONP 请求时必须指定 dataType: 'jsonp',callback=jQueryxxx 参数会被自动携带,jQueryxxx 是随机生成的回调函数名。其实现过程是动态的在 <header> 中创建和移除 <script>,并发起 JSONP 数据请求。

CORS 跨域资源共享

跨域资源共享是一系列决定浏览器是否阻止前端跨域获取资源的响应头组。通过配置接口服务器相关 HTTP 响应头可解除浏览器端跨域访问限制。

根据请求方式与请求头不同,可以分为简单请求预检请求

  • 请求方式是 GET、POST、HEAD 且 HTTP 头部信息不超过上述代码几种字段(无自定义头部字段)则为简单请求。
  • 请求方式为 GET、POST、HEAD 以外的 METHOD 类型、请求头包含自定义头部字段、向服务器发送了 application\json 格式的数据,满足其中任何一种条件都是预检请求。
const express = require("express");
// const cors = require('cors') + app.use(cors()) => 粗暴型
const app = express();
const port = 3001;
// 使用 express + node 时使用如下配置中间件配置头即可
app.use((req, res, next) => {
  // Access-Control-Allow-Origin - 允许访问该资源的外域URL
  res.header("Access-Control-Allow-Origin", "http://127.0.0.1:5500"); // 使用 * 避免只有单个源可以访问, 但是不可以携带 cookie => 保证安全
  res.header( "Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS, HEAD" );
  res.header( // 支持客户端向服务器发送如下的 9 个请求头
    "Access-Control-Allow-Headers",
    "Accept,Accept-Language, Content-Language,DPR,Downlink,Save-Data,Viewport-Width,Width,Content-Type"
  );
  res.header("Access-Control-Allow-Credentials", true); // 是否允许携带资源凭证-cookie
  // 所有 cors 预先发送 OPTIONS 试探性请求 => 用完注释即可
//   req.method === 'OPTIONS' ? res.send('SURPPORT CROSS DOMAIN REQUEST') : res.send("SORRY, you can't cross, somewhere happens error")
  next();
});
app.get("/getcors", function (req, res) { res.send("finished the request!"); });
app.listen(port, () => { console.log(`app listen is running at http://localhost:${port}`); });
<script src="./node_modules/jquery/dist/jquery.min.js"></script>
<script>
  $.ajax({
    method: "OPTIONS",
    url: "http://localhost:3001",
    success: (res) => {
      console.log(res); // SURPPORT CROSS DOMAIN REQUEST
    },
  });
  $.ajax({
    method: "get",
    url: "http://localhost:3001/getcors",
    success: (res) => {
      console.log(res); // finished the request!
    },
  });
</script>

代理服务器

Vue 项目可以在需要跨域时直接请求本地服务器 8080 端口,通过配置代理转发给远程服务器(资源服务器、数据服务器...)获取资源。注意当请求的内容存在同名于 public 目录中的文件,那么不会转发给服务器,而是直接从 public 目录读取。

// App.vue
created () {
  axios.get('/devserver').then(res => console.log(res.data))
  this.testtt()
},
methods: {
  testtt () {
    var xhr = new XMLHttpRequest()
    xhr.open('GET', 'http://localhost:8080/getcors', true)
    xhr.send()
    xhr.onreadystatechange = function () {
    if (xhr.readyState === 4 && xhr.status === 200) {
      console.log(xhr.responseText);
    }}
  }
}
// vue.config.js
// vue.config.js 打包后就不起作用. 可选项 vue.config.js 文件就等同于 webpack 的 webpack.config.js . vue-cli3 之后并不会自动创建 vue.config.js , 因为都是需要修改 webpack 的时候才会创建 vue.config.js 进行配置.
module.exports = {
  devServer: {
    host: "localhost",
    // 项目开启端口
    port: 8080,
    proxy: {
      // /getcors 表示拦截以 getcors 开头的地址
      "/getcors": {
        target: "http://localhost:3001",
        changeOrigin: true, // 是否如实禀报服务器 - 后端通过request.get('Host')查看
      },
      "/devserver": {
        target: "http://localhost:3001",
        changeOrigin: true,
      },
      "/needrewrite": {
        target: "http://localhost:3002",
        changeOrigin: true,
        // 匹配以 needrewrite 开头的路径转为空字符串.
        // 避免携带控制服务器作用的 needrewrite,被携带到请求路径中
        pathRewrite: { "^/needrewrite": "" },
        ws: true, // 用于支持 websockect
      },
    },
  },
};
// node.js 后端服务
const express = require('express');
const app = express();
const port = 3001;
app.get('/getcors',function (req, res) {
  res.send('finished the request!')
});
app.get('/devserver',function (req, res) {
  res.send('finished the request by devserver!')
});
app.listen(port, () => {
  console.log(`app listen is running at http://localhost:${port}`);
})
// 核心原理 => webpack 的 devServer.proxy 使用的 http-proxy-middleware 包
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware')
const app = express();
const port = 8080;
app.use(
  "/",
  createProxyMiddleware({
    target: "http://localhost:3001",
    changeOrigin: true
  })
)

app.listen(port, () => { console.log(`app listen is running at http://localhost:${port}`); })

结束

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!

]]>