手把手教你实现一个富文本

一. 前言

本文参考slate源码实现一个富文本,代码仓库

二. 核心对象

2.1 SlateNode

富文本内容会构建成一个虚拟SlateNode Tree,每个文本节点对应一个SlateNode节点,当修改文本内容时会更新SlateNode Tree节点数据,然后触发更新渲染,展示新的富文本内容

SlateNode Tree示意图如下

2.2 SlateEditor

富文本编辑器对象,负责维护SlateNode Tree,提供插入、删除文本等核心API

三. Browser API

3.1 beforeinput事件

富文本容器节点会监听该事件,监听用户输入操作,更新SlateNode Tree,触发重新渲染,展示新的富文本内容,更多内容参考文档

3.2 selectionchange事件

监听用户光标选择内容,记录到SlateEditor对象属性上,更多内容参考文档

3.3 document.getSelection方法

获取Selection对象,通过其setBaseAndExtent方法设置光标位置,更多内容参考文档

四. 实现富文本

4.1 定义SlateNode对象原型

SlateNode对象的tag属性代表对应DOM节点标签,如pspanh1等等,flags属性代表副作用,如加粗,斜体等

ELEMENT_TO_NODE用来记录DOM节点与SlateNode节点的映射关系,在渲染DOM节点时会添加映射关系

typescript 复制代码
// dom节点与slateNode节点映射关系
export const ELEMENT_TO_NODE: WeakMap<Node, SlateNode> = new WeakMap()

class SlateNode {
  tag: keyof JSX.IntrinsicElements // 节点标签
  key: string // 唯一标识
  text: string // 文本内容
  children: SlateNode[] // 子节点
  parent: SlateNode | null // 父节点
  stateNode: HTMLElement | null // DOM节点
  path: number[] // 节点路径
  bold?: boolean // 是否加粗
  code?: boolean // 是否是代码块
  italic?: boolean // 是否是斜体
  underline?: boolean // 是否有下划线
  align?: 'left' | 'center' | 'right' // 文本水平位置
  flags: number // 副作用,如加粗,斜体
  attributes: {
    className?: string // class类名
    style?: CSSProperties // style样式
    contentEditable?: boolean // 是否可编辑
  }

  constructor({
    tag = 'div',
    key = uniqueId(),
    text = '',
    children = [],
    parent = null,
    stateNode = null,
    path = [],
    bold,
    code,
    italic,
    underline,
    align,
    flags = NoFlags,
    attributes = {},
  }: Partial<SlateNode>) {
    this.tag = tag
    this.key = key
    this.text = text
    this.children = children
    this.parent = parent
    this.stateNode = stateNode
    this.path = path
    this.bold = bold
    this.code = code
    this.italic = italic
    this.underline = underline
    this.align = align
    this.flags = flags
    this.attributes = attributes
  }
}

4.2 定义SlateEditor对象

SlateEditor对象的domEl属性记录编辑容器节点,通常是divslateRootNode属性指向SlateNode Tree根节点,slateSelection属性记录光标选择区域

提供文本操作相关API,如插入文本insertText方法或删除文本deleteContentBackward方法等等

typescript 复制代码
class SlateEditor {
  domEl: HTMLElement | null // 编辑容器节点
  slateRootNode: SlateNode // SlateNode Tree根节点
  slateSelection: SlateSelection | null // 对标Selection对象,有两个属性,anchor是选择区域开始锚点,focus是选择区域结束锚点
  flags: number // 副作用,如加粗,斜体
  forceUpdate: ActionDispatch<AnyActionArg> // 触发更新渲染

  constructor({
    domEl = null,
    slateSelection = null,
    slateRootNode = new SlateNode({ tag: 'div' }),
    flags = NoFlags,
    forceUpdate = noop,
  }: Partial<SlateEditor>) {
    this.domEl = domEl
    this.slateSelection = slateSelection
    this.slateRootNode = slateRootNode
    this.flags = flags
    this.forceUpdate = forceUpdate
  }
  
  // 插入文本
  public insertText(text: string) {}
  
  // 插入行
  public insertParagraph() {}
  
  // 删除文本
  public deleteContentBackward() {}
  
  // 处理操作栏操作逻辑
  public addFlag(flag: number) {}
}

4.3 定义createEditor方法

创建SlateEditor对象实例方法,initialValue入参是富文本初始内容,如果没有传则渲染占位节点

typescript 复制代码
const renderPlaceholder = (): SlateNode =>
  new SlateNode({
    tag: 'p',
    children: [
      // \uFEFF代表非法字符即不可用字符,不会在页面上展示,作用是判断当前行是否为空
      new SlateNode({ tag: 'span', text: '\uFEFF' }),
      new SlateNode({
        tag: 'span',
        text: 'Enter some rich text...',
        attributes: {
          style: {
            position: 'absolute',
            top: 0,
            left: 0,
            opacity: 0.3,
            pointerEvents: 'none', // 忽略鼠标点击事件
            userSelect: 'none', // 内容不可选中
          },
          contentEditable: false,
      }),
    ],
  })
  
function createEditor(initialValue?: SlateNode[]): SlateEditor {
  const editor = new SlateEditor({
    slateRootNode: new SlateNode({
      tag: 'div',
      children: initialValue || [renderPlaceholder()],
    }),
  })
  return editor
}

4.4 定义Slate组件

负责初始化SlateEditor对象实例,赋值给SlateContext。提供forceUpdate更新渲染方法赋值给SlateEditor对象

typescript 复制代码
function Slate({ initialValue }: { initialValue?: SlateNode[] }) {
  const [, forceUpdate] = useReducer((s) => s + 1, 0)
  const editor = useMemo(() => createEditor(initialValue), [])

  useEffect(() => {
    editor.forceUpdate = forceUpdate
    return () => {
      editor.forceUpdate = noop
    }
  }, [])

  return (
    <SlateContext.Provider value={{ editor }}>
      <div className='editable-container'>
        {/* 操作栏 */}
        <Toolbar />
        {/* 编辑区 */}
        <Editable />
      </div>
    </SlateContext.Provider>
  )
}

4.5 操作栏Toolbar

4.5.1 定义Icon组件

提供修改字体样式的操作,如加粗、斜体或代码块等等

4.5.1.1 引入图标字体
css 复制代码
@font-face {
  font-family: 'Material Icons';
  font-style: normal;
  font-weight: 400;
  src: url(https://fonts.gstatic.com/s/materialicons/v143/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2)
  format('woff2');
}

.material-icons {
  font-family: 'Material Icons';
  font-weight: normal;
  font-style: normal;
  font-size: 24px;
  line-height: 1;
  letter-spacing: normal;
  text-transform: none;
  display: inline-block;
  white-space: nowrap;
  word-wrap: normal;
  direction: ltr;
  -webkit-font-feature-settings: 'liga';
  -webkit-font-smoothing: antialiased;
}
4.5.1.2 实现Icon组件

flag参数代表副作用,如加粗,斜体等,会记录到editor.flags属性上

typescript 复制代码
function Icon(props: PropsWithChildren<{ flag: number }>) {
  const editor = useSlate()
  const { flag, ...rest } = props

  return (
    <span
      onClick={() => {
        editor.addFlag(flag)
      }}
      className={`material-icons editable-icon ${editor.flags & flag ? 'editable-icon-active' : ''}`}
      {...rest}
    />
  )
}

4.5.2 定义Toolbar组件

typescript 复制代码
function Toolbar() {
  return (
    <div className='editable-toolbar'>
      {/* 加粗 */}
      <Icon flag={Bold}>format_bold</Icon>
      {/* 斜体 */}
      <Icon flag={Italic}>format_italic</Icon>
      {/* 下划线 */}
      <Icon flag={Underline}>format_underlined</Icon>
      {/* 代码块 */}
      <Icon flag={Code}>code</Icon>
      {/* h1 */}
      <Icon flag={HeadingOne}>looks_one</Icon>
      {/* h2 */}
      <Icon flag={HeadingTwo}>looks_two</Icon>
      {/* blockquote */}
      <Icon flag={BlockQuote}>format_quote</Icon>
      {/* ol */}
      <Icon flag={NumberedList}>format_list_numbered</Icon>
      {/* ul */}
      <Icon flag={BulletedList}>format_list_bulleted</Icon>
      {/* 文本居左 */}
      <Icon flag={TextAlignLeft}>format_align_left</Icon>
      {/* 文本居中 */}
      <Icon flag={TextAlignCenter}>format_align_center</Icon>
      {/* 文本居右 */}
      <Icon flag={TextAlignRight}>format_align_right</Icon>
      {/* 文本自适应 */}
      <Icon flag={TextAlignJustify}>format_align_justify</Icon>
    </div>
  )
}

4.6 编辑区

通过给div节点添加contentEditable属性实现内容可编辑

typescript 复制代码
function Editable() {
  const editor = useSlate()
  
  return (
    <div
      ref={(el) => {
        editor.domEl = el
        return () => {
          editor.domEl = null
        }
      }}
      className='editable-content'
      contentEditable
      suppressContentEditableWarning
      data-slate-editor
    >
      <Children />
    </div>
  )
}

4.6.1 监听selectionchange事件

通过document.getSelection方法获取Selection对象,创建其对应的SlateSelection对象,主要记录光标选择区域开始锚点和结束锚点

typescript 复制代码
function Editable() {
  useEffect(() => {
    const onDOMSelectionChange = () => {
      const domSelection = document.getSelection()
      if (!domSelection) return
      const { anchorNode, focusNode } = domSelection
      if (
        editor.domEl?.contains(anchorNode) &&
        editor.domEl?.contains(focusNode)
      ) {
        editor.slateSelection = SlateSelection.toSlateSelection(domSelection)
        editor.bubbleProperties()
        editor.forceUpdate()
      }
    }
    document.addEventListener('selectionchange', onDOMSelectionChange)
    return () => {
      document.removeEventListener('selectionchange', onDOMSelectionChange)
    }
  }, [])
}

4.6.2 监听beforeinput事件

通过event对象的inputType属性可以知道用户操作类型,如insertText代表插入文本

typescript 复制代码
function Editable() {
  useEffect(() => {
    const onDOMBeforeInput = (event: InputEvent) => {
      event.preventDefault()
      switch (event.inputType) {
         // 插入文本
        case 'insertText':
          if (!event.data) return
          editor.insertText(event.data)
          break
        // 插入行
        case 'insertParagraph':
          editor.insertParagraph()
          break
        // 删除文本
        case 'deleteContentBackward':
          editor.deleteContentBackward()
          break
      }
    }
    editor.domEl?.addEventListener('beforeinput', onDOMBeforeInput)
    return () => {
      editor.domEl?.removeEventListener('beforeinput', onDOMBeforeInput)
    }
  }, [])
  
  ...
}

4.6.3 设置光标选择区域

由于在beforeinput事件中调用了event.preventDefault方法阻止了浏览器默认行为,所以需要手动设置光标位置

typescript 复制代码
function Editable() {
  useEffect(() => {
    // 获取Selection对象
    const selection = document.getSelection()
    if (!selection || !editor.slateSelection) return
    // 获取StaticRange对象
    const range = SlateSelection.toDOMRange(
      editor.slateRootNode,
      editor.slateSelection,
    )
    const { startContainer, startOffset, endContainer, endOffset } = range
    // 设置光标选择区域
    selection.setBaseAndExtent(
      startContainer,
      startOffset,
      endContainer,
      endOffset,
    )
  })
  
  ...
}

4.6.4 渲染子节点

path属性值是number类型数组,代表该节点在SlateNode Tree中的位置,如path[0, 0],说明该节点在第一行第一列位置,通过该path属性也可以获取其对应的DOM节点

typescript 复制代码
function useChildren({ node }: { node: SlateNode }) {
  const children = []
  for (let i = 0; i < node.children.length; i++) {
    const child = node.children[i]
    // 计算节点路径
    child.path = node.tag === 'div' ? [i] : node.path.concat([i])
    // 建立父子关系索引
    child.parent = node
    if (child.tag === 'span') {
      // 行内元素节点
      children.push(<TextComponent key={child.key} node={child} />)
    } else {
      // 块级元素节点
      children.push(<ElementComponent key={child.key} node={child} />)
    }
  }
  return children
}

function Children() {
  const editor = useSlate()
  return useChildren({ node: editor.slateRootNode })
}
4.6.4.1 定义Element组件
typescript 复制代码
function renderElement({
  attributes,
  node,
  children,
}: PropsWithChildren<{
  attributes: { [key: string]: unknown }
  node: SlateNode
}>) {
  const style = { textAlign: node.align }
  // 元素标签
  const Component = node.tag

  return (
    <Component style={style} {...attributes}>
      {children}
    </Component>
  )
}

function Element({ node }: { node: SlateNode }) {
  const children = useChildren({ node })

  const attributes = {
    ...node.attributes,
    'data-slate-node': 'element',
    ref(el: HTMLElement) {
      // 记录DOM节点
      node.stateNode = el
      // 记录DOM节点和SlateNode节点映射关系
      ELEMENT_TO_NODE.set(el, node)

      return () => {
        node.stateNode = null
        ELEMENT_TO_NODE.delete(el)
      }
    },
  }

  return renderElement({ attributes, children, node })
}
4.6.4.2 定义Text组件
typescript 复制代码
function renderText({
  attributes,
  node,
  children,
}: PropsWithChildren<{
  attributes: { [key: string]: unknown }
  node: SlateNode
}>) {
  if (node.bold) {
    children = <strong>{children}</strong>
  }
  if (node.code) {
    children = <code>{children}</code>
  }
  if (node.italic) {
    children = <em>{children}</em>
  }
  if (node.underline) {
    children = <u>{children}</u>
  }
  return <span {...attributes}>{children}</span>
}

function Text({ node }: { node: SlateNode }) {
  const attributes = {
    ...node.attributes,
    'data-slate-node': 'text',
    ref(el: HTMLElement) {
      // 记录DOM节点
      node.stateNode = el
      // 记录DOM节点和SlateNode节点映射关系
      ELEMENT_TO_NODE.set(el, node)

      return () => {
        node.stateNode = null
        ELEMENT_TO_NODE.delete(el)
      }
    },
  }

  return renderText({
    attributes,
    node,
    children: <span data-slate-leaf>{node.text}</span>,
  })
}

4.7 实现SlateEditor API

4.7.1 insertText

判断当前行是否以\uFEFF开头,是则说明当前行没有输入内容,直接替换成输入文本内容即可,不是则对当前文本进行截取,将输入内容插入到当前文本中

typescript 复制代码
class SlateEditor {
  public insertText(text: string) {
    // 移除占位节点
    this.removePlaceholder()
    // anchor为选择区域开始锚点,focus为选择区域结束锚点
    const { anchor, focus } = this.slateSelection!
    // 获取开始锚点对应的SlateNode节点
    const slateNode = SlateNode.toSlateNodeByPath(
      this.slateRootNode,
      anchor.path,
    )
    // 如果文本内容以\uFEFF开头,直接替换成输入文本内容即可
    if (slateNode.isEmpty()) {
      slateNode.text = text
      this.forceUpdate()
      return
    }
    // 获取光标前半部分文本
    const before = slateNode.text.slice(0, anchor.offset)
    // 获取光标后半部分文本
    const after = slateNode.text.slice(anchor.offset)
    // 将输入文本插入到光标位置
    slateNode.text = before + text + after
    anchor.offset += 1
    focus.offset += 1
    this.forceUpdate()
  }
}

4.7.2 insertParagraph

判断光标位置后面是否有文本,有则需要将其添加到新行中,并将其从旧行中删除

typescript 复制代码
class SlateEditor {
  public insertParagraph() {
    // 移除占位节点
    this.removePlaceholder()
    const { anchor, focus } = this.slateSelection!
    // 获取换行文本内容
    let text = ''
    // 开始锚点对应的SlateNode节点
    const slateNode = SlateNode.toSlateNodeByPath(
      this.slateRootNode,
      anchor.path,
    )
    text += slateNode.text.slice(anchor.offset)
    slateNode.text = slateNode.text.slice(0, anchor.offset)
    const parentNode = slateNode.parent!
    const childrenList = parentNode.children.splice(
      slateNode.path[slateNode.path.length - 1] + 1,
    )
    childrenList.unshift(renderText({ flags: slateNode.flags, text }))
    // 如果当前行文本为空则赋值为\uFEFF
    if (parentNode.children.length === 1 && !parentNode.children[0].text)
      parentNode.children = renderParagraph().children
    // 新行坐标
    const paragraphLocation = parentNode.path[parentNode.path.length - 1] + 1
    // 当前行根节点
    const rootNode = parentNode.parent!
    const before = rootNode.children.slice(0, paragraphLocation)
    const after = rootNode.children.slice(paragraphLocation)
    const children = ['ol', 'ul'].includes(rootNode.tag)
      ? renderListItem({
          flags: parentNode.flags,
          children: childrenList,
          path: [...rootNode.path, paragraphLocation],
        })
      : renderParagraph({
          tag: parentNode.tag,
          flags: parentNode.flags,
          children: childrenList,
          path: [...rootNode.path, paragraphLocation],
        })
    rootNode.children = [...before, children, ...after]
    anchor.path = focus.path = [...children.path, 0]
    anchor.offset = focus.offset = 0
    this.forceUpdate()
  }
}

4.7.3 deleteContentBackward

如果是在行开头进行删除操作,判断光标位置后面是否有文本,有则需要将其添加到上一行末尾,并将其从旧行中删除

typescript 复制代码
class SlateEditor {
  public deleteContentBackward() {
    if (this.isEmpty()) return
    const { anchor, focus } = this.slateSelection!
    let slateNode = SlateNode.toSlateNodeByPath(this.slateRootNode, anchor.path)
    const parentNode = slateNode.parent!
    const rootNode = parentNode.parent!
    // 当前行开头删除处理逻辑
    if (slateNode.isEmpty() || !anchor.offset) {
      // 在首行开头则直接跳过
      if (anchor.path.every((p) => p === 0)) return
      // 当前行坐标
      const paragraphLocation = parentNode.path[parentNode.path.length - 1]
      const [paragraph] = rootNode.children.splice(paragraphLocation, 1)
      if (!rootNode.children.length)
        this.slateRootNode.children.splice(rootNode.path[0], 1)
      // 获取当前行文本内容
      const text = paragraph.isEmpty() ? '' : paragraph.string()
      const prevParagraph =
        paragraphLocation === 0
          ? this.slateRootNode.children[rootNode.path[0] - 1]
          : rootNode.children[paragraphLocation - 1]
      let lastChild = prevParagraph
      while (lastChild.tag !== 'span')
        lastChild = lastChild.children[lastChild.children.length - 1]
      lastChild.text =
        text && lastChild.isEmpty() ? text : lastChild.text + text
      if (this.isEmpty()) this.renderPlaceholder()
      anchor.path = focus.path = lastChild.path
      anchor.offset = focus.offset = lastChild.text.length - text.length
      this.bubbleProperties()
      this.forceUpdate()
      return
    }
    // 获取光标前半部分文本
    const before = slateNode.text.slice(0, anchor.offset - 1)
    // 获取光标后半部分文本
    const after = slateNode.text.slice(anchor.offset)
    slateNode.text = before + after
    if (slateNode.text) {
      anchor.offset -= 1
      focus.offset -= 1
      this.forceUpdate()
      return
    }
    parentNode.children.splice(parentNode.children.indexOf(slateNode), 1)
    if (!parentNode.children.length) {
      if (this.isEmpty()) this.renderPlaceholder()
      else parentNode.children = renderParagraph().children
      anchor.offset = focus.offset = 1
    } else {
      // 移动到上一个子节点
      anchor.path[anchor.path.length - 1] -= 1
      focus.path[focus.path.length - 1] -= 1
      slateNode = SlateNode.toSlateNodeByPath(this.slateRootNode, anchor.path)
      anchor.offset = focus.offset = slateNode.text.length
    }
    this.bubbleProperties()
    this.forceUpdate()
  }
}

4.7.4 addFlag

运用位运算技巧添加或删除相关的操作,例如this.flags |= Bold代表添加加粗操作,this.flags &= ~Bold代表移除加粗操作

对于SlateNode Tree主要有如下三种处理逻辑

  • 第一种是textAlign,即控制文本水平位置,添加style对象对应的textAlign属性值即可
  • 第二种是行内元素标签,如strongemu等,需要修改SlateNode节点的flags属性值,根据flags属性值修改SlateNode节点的bolditalicunderlinecode属性
  • 第三种是块级元素标签,如h1h2blockquote等,需要更改SlateNode节点的tag属性,如果是涉及列表元素,还需要调整子树结构
typescript 复制代码
class SlateEditor {
  public addFlag(flag: number) {
    // 移除占位节点
    this.removePlaceholder()
    const { anchor, focus } = this.slateSelection!
    switch (flag) {
      case TextAlignLeft:
      case TextAlignCenter:
      case TextAlignRight:
      case TextAlignJustify: {
        const slateNode = SlateNode.toSlateNodeByPath(
          this.slateRootNode,
          anchor.path,
        )
        const parentNode = slateNode.parent!
        parentNode.addFlag(flag, HasTextAlign)
        parentNode.setProps(['textAlign'])
        break
      }
      case Bold:
      case Italic:
      case Underline:
      case Code: {
        const slateNode = SlateNode.toSlateNodeByPath(
          this.slateRootNode,
          anchor.path,
        )
        slateNode.addFlag(flag, NoFlags)
        slateNode.setProps(['bold', 'italic', 'underline', 'code'])
        break
      }
      case HeadingOne:
      case HeadingTwo:
      case BlockQuote:
      case NumberedList:
      case BulletedList: {
        let tag: keyof JSX.IntrinsicElements = 'p'
        switch (flag) {
          case HeadingOne:
            tag = 'h1'
            break
          case HeadingTwo:
            tag = 'h2'
            break
          case BlockQuote:
            tag = 'blockquote'
            break
          case NumberedList:
            tag = 'ol'
            break
          case BulletedList:
            tag = 'ul'
            break
        }
        const slateNode = SlateNode.toSlateNodeByPath(this.slateRootNode, [
          anchor.path[0],
        ])
        if (['p', 'h1', 'h2', 'blockquote'].includes(slateNode.tag)) {
          slateNode.addFlag(flag, HasBlockElement)
          slateNode.tag = slateNode.flags & flag ? tag : 'p'
          if (['ol', 'ul'].includes(slateNode.tag)) {
            slateNode.children = [
              renderListItem({
                flags: slateNode.flags & HasTextAlign,
                children: slateNode.children,
              }),
            ]
            slateNode.flags &= ~HasTextAlign
            slateNode.setProps(['textAlign'])
          }
          break
        }
        const parentIndex = anchor.path[0]
        const childIndex = anchor.path[1]
        let children = slateNode.children[childIndex]
        const childrenList = slateNode.children.splice(childIndex + 1)
        // 移除当前li节点
        slateNode.children.pop()
        // 如果当前节点tag和新tag一致,则将该li元素切换成p元素
        if (slateNode.tag === tag) {
          children.tag = 'p'
          children.addFlag(NoFlags, HasBlockElement)
        }
        // 如果新tag属于h1、h2、blockquote,那切换成对应的tag元素
        else if (['h1', 'h2', 'blockquote'].includes(tag)) {
          children.tag = tag
          children.addFlag(flag, HasBlockElement)
        } else {
          children = renderList({
            tag: tag,
            flags: tag === 'ol' ? NumberedList : BulletedList,
            children: [children],
          })
        }
        this.slateRootNode.children = [
          ...this.slateRootNode.children.slice(0, parentIndex),
          ...(slateNode.children.length ? [slateNode] : []),
          children,
          ...(childrenList.length
            ? [
                renderList({
                  tag: slateNode.tag,
                  flags: slateNode.flags,
                  children: childrenList,
                }),
              ]
            : []),
          ...this.slateRootNode.children.slice(parentIndex + 1),
        ]
        anchor.path = focus.path = [
          slateNode.children.length ? parentIndex + 1 : parentIndex,
          0,
          ...(slateNode.tag === tag || ['h1', 'h2', 'blockquote'].includes(tag)
            ? []
            : [0]),
        ]
        break
      }
    }
    // 收集子树副作用
    this.bubbleProperties()
    if (this.flags === NoFlags && this.isEmpty()) this.renderPlaceholder()
    this.forceUpdate()
  }
}

五. 结束语

我们通过将富文本内容转化成一棵虚拟DOM树,根据用户输入操作类型修改虚拟DOM树节点数据,然后触发更新渲染,展示新的富文本内容

对于操作栏操作记录,通过位运算技巧进行判断,可以高效添加或删除副作用。

创作不易,如果文章对你有帮助,那点个小小的赞吧,你的支持是我持续更新的动力!

相关推荐
爱是小小的癌2 分钟前
Java-数据结构-优先级队列(堆)
java·前端·数据结构
傻小胖40 分钟前
vue3中Teleport的用法以及使用场景
前端·javascript·vue.js
wl85111 小时前
Vue 入门到实战 七
前端·javascript·vue.js
Enti7c2 小时前
用 HTML、CSS 和 JavaScript 实现抽奖转盘效果
前端·css
LCG元2 小时前
Vue.js组件开发-使用Vue3如何实现上传word作为打印模版
前端·vue.js·word
dal118网工任子仪2 小时前
94,【2】buuctf web [安洵杯 2019]easy_serialize_php
android·前端·php
大模型铲屎官3 小时前
HTML5 技术深度解读:本地存储与地理定位的最佳实践
前端·html·html5·本地存储·localstorage·地理定位·geolocation api
一 乐3 小时前
基于vue船运物流管理系统设计与实现(源码+数据库+文档)
前端·javascript·数据库·vue.js·spring boot·后端·船运系统
m0_528723814 小时前
在React中使用redux
前端·javascript·react.js
傻小胖4 小时前
vue3中customRef的用法以及使用场景
前端·javascript·vue.js