vue3 源码学习,实现一个 mini-vue(十四):构建 compile 编译器(上)

简介: vue3 源码学习,实现一个 mini-vue(十四):构建 compile 编译器(上)

前言

原文来自我的个人博客

接下来的几章中我们将要在 mini-vue 中 实现一个自己的编译器,在上一章我们了解了 compiler 的作用和大致流程。

它主要经历了以下三个大的步骤:

  1. 解析( parse ) template 模板,生成 AST
  2. 转化(transformAST,得到 JavaScript AST
  3. 生成(generaterender 函数

那么本章我们就先来实现编译器的第一步:依据模板生成 AST 抽象语法树

ps: compiler 是一个非常复杂的概念,我们不会实现一个完善的编译器,而是会像之前一样严格遵循: 没有使用就当做不存在 和  最少代码的实现逻辑 这两个标准。只关注  核心 和  当前业务 相关的内容,而忽略其他。

1. 扩展知识:JavaScript与有限自动状态机

想要实现 compiler 第一步是构建 AST 对象。那么想要构建 AST,就需要利用到 有限状态机 的概念。

有限状态机也被叫做 有限自动状态机,表示:有限个状态以及在这些状态之间的转移和动作等行为的数学计算模型)

光看概念,可能难以理解,那么下面我们来看一个具体的例子:

根据 packages/compiler-core/src/compile.ts 中的代码可知,ast 对象的生成是通过 baseParse 方法得到的。

const ast = isString(template) ? baseParse(template, options) : template

而对于 baseParse 方法而言,它接收一个 template 作为参数,返回一个 ast 对象。

即:通过 parse 方法,解析 template,得到 ast 对象。  中间解析的过程,就需要使用到 有限自动状态机。

比如,vue 想要把如下模板解析成 AST,那么就需要利用有限自动状态机对该模板进行分析

<div>hello world</div>

分析的过程中主要包含了三个特性:摘自阮一峰:JavaScript与有限状态机

  1. 状态总数(state)是有限的。

    1. 初始状态
    2. 标签开始状态
    3. 标签名称状态
    4. 文本状态
    5. 结束标签状态
    6. 结束标签名称状态
    7. ...
  2. 任一时刻,只处在一种状态之中。
  3. 某种条件下,会从一种状态转变(transition)到另一种状态。

如下图所示:
image.png

  1. 解析 <:由 初始状态 进入 标签开始状态
  2. 解析 div:由 标签开始状态 进入 标签名称状态
  3. 解析 >:由 标签名称状态 进入 初始状态
  4. 解析 hello world:由 初始状态 进入 文本状态
  5. 解析 <:由 文本状态 进入 标签开始状态
  6. 解析 /:由 标签开始状态 进入 结束标签状态
  7. 解析 div:由 结束标签状态 进入 结束标签名称状态
  8. 解析 >:由 结束标签名称状态 进入 初始状态

经过这样一些列的解析,对于:

<div>hello world</div>

而言,我们将得到三个 token

开始标签:<div>
文本节点:hello world
结束标签:</div>

而这样一个利用有限自动状态机的状态迁移,来获取 tokens 的过程,可以叫做:对模板的标记化

总结

这一小节,我们了解了什么是有限自动状态机,也知道了它的三个特性。

vue 利用它来实现了对模板的标记化,得到了对应的 token

关于这些 token 有什么用,我们下一小节说。

2. 扩展知识:扫描 tokens 构建 AST 结构的方案

在上一小节中,我们已经知道可以通过自动状态机解析模板为 tokens,而解析出来的 tokens 就是生成 AST 的关键。

生成 AST 的过程,就是 tokens 扫描的过程

我们以以下 html 结构为例:

<div>
  <p>hello</p>  
  <p>world</p>  
</div>

该 html 可以被解析为如下 tokens

开始标签:<div>
开始标签:<p>
文本节点:hello
结束标签:</p>
开始标签:<p>
文本节点:world
结束标签:</p>
结束标签:</div>

具体的扫描过程为:

1. 初始状态:

image.png

2. 扫描开始标签 <div>,在 AST 下 会生成 Element<div>

image.png

3. <p>hello</p> 标签依次入栈, AST 生成对应 Element<p>

image.png

  1. <p>hello</p> 标签依次出栈,<p>world</p> 标签依次入栈,AST 生成对应 Element<p>

image.png

5. </div>结束标签入栈再与<div>开始标签组合依次出栈。
image.png

6. 结束状态

image.png

在以上的图示中,我们通过 递归下降算法? 这样的一种扫描形式把 tokens 通过  解析成了 AST(抽象语法树)

3. 源码阅读:依据模板生成 AST 抽象语法树

前面做了这么多铺垫,我们中行与可以来看一下 vue 中生成 AST 的代码了。vue 的这部分代码全部放在了 packages/compiler-core/src/parse.ts 中:

image.png

从这个文件可以看出,整体 parse 的逻辑非常复杂,整体文件有 1175 行代码。

通过 packages/compiler-core/src/compile.ts 中的 baseCompile 方法可以知道,整个 parse 的过程是从 baseParse 开始的,所以我们可以直接从这个方法开始进行 debugger

创建测试实例:

<script>
  const { compile, h, render } = Vue
  // 创建 template
  const template = `<div>hello world</div>`

  // 生成 render 函数
  const renderFn = compile(template)
</script>

当前的 template 对应的目标极简 AST 为(我们不再关注其他的属性生成):

const ast = {
  "type": 0,
  "children": [
    {
      "type": 1,
      "tag": "div",
      "tagType": 0,
      // 属性,目前我们没有做任何处理。但是需要添加上,否则,生成的 ats 放到 vue 源码中会抛出错误
      "props": [],
      "children": [{ "type": 2, "content": " hello world " }]
    }
  ],
  // loc:位置,这个属性并不影响渲染,但是它必须存在,否则会报错。所以我们给了他一个 {}
  "loc": {}
}

模板解析的 token 流程为(以 <div>hello world</div> 为例):

1. <div
2. >
3. hello world
4. </div
5. >

明确好以上内容之后,我们开始。Here we go

  1. 进入 baseParse 方法:

image.png

  1. 上图描述的应该比较详细了,在 baseParse 方法中,程序首先会进入 createParserContext 方法中,这个方法会返回一个 ParserContext 类型的对象,这个对象比较复杂,我们只需要关注 source(模板源代码)属性即可
  2. 此时 context.source = "<div> hello world </div>",程序接着执行:

image.png

  1. createParserContext 方法跳出后,程序接着执行 getCursor 方法,这个方法主要获取 loc(即:location 位置),与我们的极简 `AST 无关,无需关注,接着调试:

image.png

  1. 接着,程序会先后执行 parseChildrengetSelectioncreateRoot 三个方法,最后返回 ast
  2. 执行 parseChildren 方法(解析子节点) ,这个方法 非常重要,是生成 AST 的核心方法:

image.png

  1. parseChildren 方法中的核心逻辑都在 while 循环中,它的目的就是循环解析模板数据,生成 AST 中的 children 的,最后生成的 nodes:(关于 while 循环中的逻辑是很复杂的,碍于篇幅这里做了点省略,具体逻辑可以参考我下面的框架实现)

image.png

  1. 接下来执行 getSelection 方法:

image.png

  1. 这个函数非常简单,也不是那么重要,我们只要知道是关于 location 的生成就行了。
  2. 最后,执行 createRoot 方法,这个方法也非常简单,就是整合了前两个函数生成的 childrenloc 成一个 RootNode 对象(最后的 AST)并返回

image.png

4. 框架实现

AST 对象的生成颇为复杂,我们把整个过程分为成三步进行处理。

  1. 构建 parse 方法,生成 context 实例
  2. 构建 parseChildren ,处理所有子节点(最复杂

    1. 构建有限自动状态机解析模板
    2. 扫描 token 生成 AST 结构
  3. 生成 AST,构建测试

4.1 构建 parse 函数,生成 context 实例

  1. 创建 packages/compiler-core/src/compile.ts 模块,写入如下代码:
export function baseCompile(template: string, options) {
  return {}
}
  1. 创建 packages/compiler-dom/src/index.ts 模块,导出 compile 方法:
import { baseCompile } from 'packages/compiler-core/src/compile'

export function compile(template: string, options) {
  return baseCompile(template, options)
}
  1. 在 packages/vue/src/index.ts 中,导出 compile 方法:
export { render, compile } from '@vue/runtime-dom'
  1. 创建 packages/compiler-core/src/parse.ts 模块下创建 baseParse 方法:
/**
 * 基础的 parse 方法,生成 AST
 * @param content tempalte 模板
 * @returns
 */
export function baseParse(content: string) {
  return {}
}
  1. 在 packages/compiler-core/src/compile.ts 模块下的 baseCompile 中,使用 baseParse 方法:
import { baseParse } from './parse'

export function baseCompile(template: string, options) {
  const ast = baseParse(template)
  console.log(JSON.stringify(ast))

  return {}
}

至此,我们就成功的触发了 baseParse。接下来我们去生成 context 上下文对象。

  1. 在 packages/compiler-core/src/parse.ts 中创建 createParserContext 方法,用来生成上下文对象:
/**
 * 创建解析器上下文
 */
function createParserContext(content: string): ParserContext {
  // 合成 context 上下文对象
  return {
    source: content
  }
}
  1. 创建 ParserContext 接口:
/**
 * 解析器上下文
 */
export interface ParserContext {
  // 模板数据源
  source: string
}
  1. 在 baseParse 中触发该方法:
export function baseParse(content: string) {
  // 创建 parser 对象,未解析器的上下文对象
  const context = createParserContext(content)
  console.log(context)
  return {}
}

至此我们成功得到了 context 上下文对象。

创建测试实例 packages/vue/examples/compiler/compiler-ast.html

<script>
  const { compile } = Vue
  // 创建 template
  const template = `<div> hello world </div>`

  // 生成 render 函数
  const renderFn = compile(template)
</script>

可以成功打印 context

image.png

4.2 构建有限自动状态机解析模板,扫描 token 生成 AST 结构

接下来我们通过 parseChildren 方法处理所有的子节点,整个处理的过程分为两大块:

  1. 构建有限自动状态机解析模板
  2. 扫描 token 生成 AST 结构

接下来我们来进行实现:

  1. 创建 parseChildren 方法:
/**
 * 解析子节点
 * @param context 上下文
 * @param mode 文本模型
 * @param ancestors 祖先节点
 * @returns
 */
function parseChildren(context: ParserContext, ancestors) {
  // 存放所有 node节点数据的数组
  const nodes = []

  /**
   * 循环解析所有 node 节点,可以理解为对 token 的处理。
   * 例如:<div>hello world</div>,此时的处理顺序为:
   * 1. <div
   * 2. >
   * 3. hello world
   * 4. </
   * 5. div>
   */
  while (!isEnd(context, ancestors)) {
    /**
     * 模板源
     */
    const s = context.source
    // 定义 node 节点
    let node

    if (startsWith(s, '{{')) {
    }
    // < 意味着一个标签的开始
    else if (s[0] === '<') {
      // 以 < 开始,后面跟a-z 表示,这是一个标签的开始
      if (/[a-z]/i.test(s[1])) {
        // 此时要处理 Element
        node = parseElement(context, ancestors)
      }
    }

    // node 不存在意味着上面的两个 if 都没有进入,那么我们就认为此时的 token 为文本节点
    if (!node) {
      node = parseText(context)
    }

    pushNode(nodes, node)
  }

  return nodes
}
  1. 以上代码中涉及到了 个方法:

    1. isEnd:判断是否为结束节点
    2. startsWith:判断是否以指定文本开头
    3. pushNode:为 array 执行 push 方法
    4. 复杂: parseElement:解析 element
    5. 复杂: parseText:解析 text
  2. 我们先实现前三个简单方法:
  3. 创建 startsWith 方法:
/**
 * 是否以指定文本开头
 */
function startsWith(source: string, searchString: string): boolean {
  return source.startsWith(searchString)
}
  1. 创建 isEnd 方法:
/**
 * 判断是否为结束节点
 */
function isEnd(context: ParserContext, ancestors): boolean {
  const s = context.source

  // 解析是否为结束标签
  if (startsWith(s, '</')) {
    for (let i = ancestors.length - 1; i >= 0; --i) {
      if (startsWithEndTagOpen(s, ancestors[i].tag)) {
        return true
      }
    }
  }
  return !s
}

/**
 * 判断当前是否为《标签结束的开始》。比如 </div> 就是 div 标签结束的开始
 * @param source 模板。例如:</div>
 * @param tag 标签。例如:div
 * @returns
 */
function startsWithEndTagOpen(source: string, tag: string): boolean {
  return (
    startsWith(source, '</') &&
    source.slice(2, 2 + tag.length).toLowerCase() === tag.toLowerCase() &&
    /[\t\r\n\f />]/.test(source[2 + tag.length] || '>')
  )
}
  1. 创建 pushNode 方法:
/**
 * nodes.push(node)
 */
function pushNode(nodes, node): void {
  nodes.push(node)
}

至此三个简单的方法都被构建完成。

接下来我们来处理 parseElement,在处理的过程中,我们需要使用到 NodeTypes 和 ElementTypes 这两个 enum 对象,所以我们需要先构建它们(直接从 vue 源码复制):

  1. 创建 packages/compiler-core/src/ast.ts 模块:
/**
 * 节点类型(我们这里复制了所有的节点类型,但是我们实际上只用到了极少的部分)
 */
export const enum NodeTypes {
  ROOT,
  ELEMENT,
  TEXT,
  COMMENT,
  SIMPLE_EXPRESSION,
  INTERPOLATION,
  ATTRIBUTE,
  DIRECTIVE,
  // containers
  COMPOUND_EXPRESSION,
  IF,
  IF_BRANCH,
  FOR,
  TEXT_CALL,
  // codegen
  VNODE_CALL,
  JS_CALL_EXPRESSION,
  JS_OBJECT_EXPRESSION,
  JS_PROPERTY,
  JS_ARRAY_EXPRESSION,
  JS_FUNCTION_EXPRESSION,
  JS_CONDITIONAL_EXPRESSION,
  JS_CACHE_EXPRESSION,

  // ssr codegen
  JS_BLOCK_STATEMENT,
  JS_TEMPLATE_LITERAL,
  JS_IF_STATEMENT,
  JS_ASSIGNMENT_EXPRESSION,
  JS_SEQUENCE_EXPRESSION,
  JS_RETURN_STATEMENT
}

/**
 * Element 标签类型
 */
export const enum ElementTypes {
  /**
   * element,例如:<div>
   */
  ELEMENT,
  /**
   * 组件
   */
  COMPONENT,
  /**
   * 插槽
   */
  SLOT,
  /**
   * template
   */
  TEMPLATE
}

下面就可以构建 parseElement 方法了 ,该方法的作用主要为了解析 Element 元素:

  1. 创建 parseElement
/**
 * 解析 Element 元素。例如:<div>
 */
function parseElement(context: ParserContext, ancestors) {
  // -- 先处理开始标签 --
  const element = parseTag(context, TagType.Start)

  //  -- 处理子节点 --
  ancestors.push(element)
  // 递归触发 parseChildren
  const children = parseChildren(context, ancestors)
  ancestors.pop()
  // 为子节点赋值
  element.children = children

  //  -- 最后处理结束标签 --
  if (startsWithEndTagOpen(context.source, element.tag)) {
    parseTag(context, TagType.End)
  }

  // 整个标签处理完成
  return element
}
  1. 构建 TagType enum
/**
 * 标签类型,包含:开始和结束
 */
const enum TagType {
  Start,
  End
}
  1. 处理开始标签,构建 parseTag
/**
 * 解析标签
 */
function parseTag(context: any, type: TagType): any {
  // -- 处理标签开始部分 --

  // 通过正则获取标签名
  const match: any = /^<\/?([a-z][^\r\n\t\f />]*)/i.exec(context.source)
  // 标签名字
  const tag = match[1]

  // 对模板进行解析处理
  advanceBy(context, match[0].length)

  // -- 处理标签结束部分 --

  // 判断是否为自关闭标签,例如 <img />
  let isSelfClosing = startsWith(context.source, '/>')
  // 《继续》对模板进行解析处理,是自动标签则处理两个字符 /> ,不是则处理一个字符 >
  advanceBy(context, isSelfClosing ? 2 : 1)

  // 标签类型
  let tagType = ElementTypes.ELEMENT

  return {
    type: NodeTypes.ELEMENT,
    tag,
    tagType,
    // 属性,目前我们没有做任何处理。但是需要添加上,否则,生成的 ats 放到 vue 源码中会抛出错误
    props: []
  }
}
  1. 解析标签的过程,其实就是一个自动状态机不断读取的过程,我们需要构建 advanceBy 方法,来标记进入下一步:
/**
 * 前进一步。多次调用,每次调用都会处理一部分的模板内容
 * 以 <div>hello world</div> 为例
 * 1. <div
 * 2. >
 * 3. hello world
 * 4. </div
 * 5. >
 */
function advanceBy(context: ParserContext, numberOfCharacters: number): void {
  // template 模板源
  const { source } = context
  // 去除开始部分的无效数据
  context.source = source.slice(numberOfCharacters)
}

至此 parseElement 构建完成。此处的代码虽然不多,但是逻辑非常复杂。在解析的过程中,会再次触发 parseChildren,这次触发表示触发 文本解析,所以下面我们要处理 parseText 方法。

  1. 创建 parseText 方法,解析文本:
/**
 * 解析文本。
 */
function parseText(context: ParserContext) {
  /**
   * 定义普通文本结束的标记
   * 例如:hello world </div>,那么文本结束的标记就为 <
   * PS:这也意味着如果你渲染了一个 <div> hell<o </div> 的标签,那么你将得到一个错误
   */
  const endTokens = ['<', '{{']
  // 计算普通文本结束的位置
  let endIndex = context.source.length

  // 计算精准的 endIndex,计算的逻辑为:从 context.source 中分别获取 '<', '{{' 的下标,取最小值为 endIndex
  for (let i = 0; i < endTokens.length; i++) {
    const index = context.source.indexOf(endTokens[i], 1)
    if (index !== -1 && endIndex > index) {
      endIndex = index
    }
  }

  // 获取处理的文本内容
  const content = parseTextData(context, endIndex)

  return {
    type: NodeTypes.TEXT,
    content
  }
}
  1. 解析文本的过程需要获取到文本内容,此时我们需要构建 parseTextData 方法:
/**
 * 从指定位置(length)获取给定长度的文本数据。
 */
function parseTextData(context: ParserContext, length: number): string {
  // 获取指定的文本数据
  const rawText = context.source.slice(0, length)
  // 《继续》对模板进行解析处理
  advanceBy(context, length)
  // 返回获取到的文本
  return rawText
}

最后在 baseParse 中触发 parseChildren 方法:

此时运行测试实例,打印出如下内容:

image.png

4.3 生成 AST,测试

当 parseChildren 处理完成之后,我们可以到 children,那么最后我们就只需要利用 createRoot 方法,把 children 放到 ROOT 节点之下即可。

  1. 创建 createRoot 方法:
/**
 * 生成 root 节点
 */
export function createRoot(children) {
  return {
    type: NodeTypes.ROOT,
    children,
    // loc:位置,这个属性并不影响渲染,但是它必须存在,否则会报错。所以我们给了他一个 {}
    loc: {}
  }
}
  1. 在 baseParse 中使用该方法:
/**
 * 基础的 parse 方法,生成 AST
 * @param content tempalte 模板
 * @returns
 */
export function baseParse(content: string) {
  // 创建 parser 对象,未解析器的上下文对象
  const context = createParserContext(content)
  const children = parseChildren(context, [])
  return createRoot(children)
}

至此整个 parse 解析流程完成。我们可以在 packages/compiler-core/src/compile.ts 中打印得到的 AST

export function baseCompile(template: string, options) {
  const ast = baseParse(template)
  console.log(JSON.stringify(ast), ast)

  return {}
}

得到的内容为:

{
  "type": 0,
  "children": [
    {
      "type": 1,
      "tag": "div",
      "tagType": 0,
      "props": [],
      "children": [{ "type": 2, "content": " hello world " }]
    }
  ],
  "loc": {}
}

我们可以把得到的该 AST 放入到 vue 的源码中进行解析,以此来验证是否正确。

在 vue 源码的 packages/compiler-core/src/compile.ts 模块下 baseCompile 方法中:

image.png

运行源码的 compile 方法,浏览器中依然可以渲染 hello world

<script>
  const { compile, h, render } = Vue
  // 创建 template
  const template = `<div>hello world</div>`

  // 生成 render 函数
  const renderFn = compile(template)

  // 创建组件
  const component = {
    render: renderFn
  }

  // 通过 h 函数,生成 vnode
  const vnode = h(component)

  // 通过 render 函数渲染组件
  render(vnode, document.querySelector('#app'))
</script>

成功运行,标记着我们的 AST 处理完成。

5. 扩展知识:AST 到 JavaScript AST 的转换策略和注意事项

相关文章
|
6天前
|
开发工具 iOS开发 MacOS
基于Vite7.1+Vue3+Pinia3+ArcoDesign网页版webos后台模板
最新版研发vite7+vue3.5+pinia3+arco-design仿macos/windows风格网页版OS系统Vite-Vue3-WebOS。
106 10
|
4月前
|
缓存 JavaScript PHP
斩获开发者口碑!SnowAdmin:基于 Vue3 的高颜值后台管理系统,3 步极速上手!
SnowAdmin 是一款基于 Vue3/TypeScript/Arco Design 的开源后台管理框架,以“清新优雅、开箱即用”为核心设计理念。提供角色权限精细化管理、多主题与暗黑模式切换、动态路由与页面缓存等功能,支持代码规范自动化校验及丰富组件库。通过模块化设计与前沿技术栈(Vite5/Pinia),显著提升开发效率,适合团队协作与长期维护。项目地址:[GitHub](https://github.com/WANG-Fan0912/SnowAdmin)。
735 5
|
1月前
|
缓存 前端开发 大数据
虚拟列表在Vue3中的具体应用场景有哪些?
虚拟列表在 Vue3 中通过仅渲染可视区域内容,显著提升大数据列表性能,适用于 ERP 表格、聊天界面、社交媒体、阅读器、日历及树形结构等场景,结合 `vue-virtual-scroller` 等工具可实现高效滚动与交互体验。
249 1
|
1月前
|
缓存 JavaScript UED
除了循环引用,Vue3还有哪些常见的性能优化技巧?
除了循环引用,Vue3还有哪些常见的性能优化技巧?
145 0
|
2月前
|
JavaScript
vue3循环引用自已实现
当渲染大量数据列表时,使用虚拟列表只渲染可视区域的内容,显著减少 DOM 节点数量。
95 0
|
4月前
|
JavaScript API 容器
Vue 3 中的 nextTick 使用详解与实战案例
Vue 3 中的 nextTick 使用详解与实战案例 在 Vue 3 的日常开发中,我们经常需要在数据变化后等待 DOM 更新完成再执行某些操作。此时,nextTick 就成了一个不可或缺的工具。本文将介绍 nextTick 的基本用法,并通过三个实战案例,展示它在表单验证、弹窗动画、自动聚焦等场景中的实际应用。
410 17
|
5月前
|
JavaScript 前端开发 算法
Vue 3 和 Vue 2 的区别及优点
Vue 3 和 Vue 2 的区别及优点
|
5月前
|
存储 JavaScript 前端开发
基于 ant-design-vue 和 Vue 3 封装的功能强大的表格组件
VTable 是一个基于 ant-design-vue 和 Vue 3 的多功能表格组件,支持列自定义、排序、本地化存储、行选择等功能。它继承了 Ant-Design-Vue Table 的所有特性并加以扩展,提供开箱即用的高性能体验。示例包括基础表格、可选择表格和自定义列渲染等。
412 6
|
4月前
|
JavaScript 前端开发 API
Vue 2 与 Vue 3 的区别:深度对比与迁移指南
Vue.js 是一个用于构建用户界面的渐进式 JavaScript 框架,在过去的几年里,Vue 2 一直是前端开发中的重要工具。而 Vue 3 作为其升级版本,带来了许多显著的改进和新特性。在本文中,我们将深入比较 Vue 2 和 Vue 3 的主要区别,帮助开发者更好地理解这两个版本之间的变化,并提供迁移建议。 1. Vue 3 的新特性概述 Vue 3 引入了许多新特性,使得开发体验更加流畅、灵活。以下是 Vue 3 的一些关键改进: 1.1 Composition API Composition API 是 Vue 3 的核心新特性之一。它改变了 Vue 组件的代码结构,使得逻辑组
1500 0
|
6月前
|
JavaScript 前端开发 UED
vue2和vue3的响应式原理有何不同?
大家好,我是V哥。本文详细对比了Vue 2与Vue 3的响应式原理:Vue 2基于`Object.defineProperty()`,适合小型项目但存在性能瓶颈;Vue 3采用`Proxy`,大幅优化初始化、更新性能及内存占用,更高效稳定。此外,我建议前端开发者关注鸿蒙趋势,2025年将是国产化替代关键期,推荐《鸿蒙 HarmonyOS 开发之路》卷1助你入行。老项目用Vue 2?不妨升级到Vue 3,提升用户体验!关注V哥爱编程,全栈开发轻松上手。
440 2