阅读视图

发现新文章,点击刷新页面。

📦 Vue CLI 项目结构超详细注释版解析

📄 1.index.html(页面 HTML 模板)完整注释

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    
    <!-- 页面标题,会被 vue.config.js 或 package.json 的 title 字段替换 -->
    <title><%= htmlWebpackPlugin.options.title %></title>

    <!-- 可选:响应式适配 -->
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <!-- favicon 图标,浏览器标签页的小图标 -->
    <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
  </head>

  <body>
    <!-- 这是 Vue 挂载的容器,main.js 会把 App.vue 渲染到这里 -->
    <div id="app"></div>
  </body>
</html>

💡 注意:<%= BASE_URL %> 是 webpack 插值语法,表示项目的基础路径,默认是 /。


📄 2.package.json更完整说明(含可能扩展字段)

{
  "name": "my-vue-app",             // 项目名称(建议用中划线)
  "version": "0.0.0",               // 当前项目版本
  "private": true,                  // 设置为 true,防止误发布到 npm 公共仓库
  "description": "A Vue.js project", // 项目描述(可选)
  "author": "你的名字",             // 作者信息(可选)
  "license": "MIT",                // 开源协议类型(可选)

  "scripts": {
    "serve": "vue-cli-service serve",   // 本地开发服务器
    "build": "vue-cli-service build",   // 构建打包命令
    "lint": "vue-cli-service lint"      // ESLint 检查代码质量
  },

  "dependencies": {
    "vue": "^3.5.17"               // Vue 框架运行时依赖
  },

  "devDependencies": {
    "@vue/cli-service": "^5.0.0",  // CLI 工具,封装 webpack 构建行为
    "@vue/compiler-sfc": "^3.5.17" // 编译 .vue 单文件组件(SFC)
  },

  "browserslist": [               // 配置目标浏览器,用于 babel/polyfill 优化
    "> 1%",
    "last 2 versions",
    "not dead"
  ]
}

📄 3.package-lock.json说明

这是由 npm 自动生成的“依赖版本锁定文件”,作用是:

  • 精确记录每个依赖的具体版本、来源地址和校验 hash;

  • 避免开发、生产或团队成员机器之间安装到不同版本的依赖;

  • 不建议手动修改,建议提交到 Git 仓库中。

你不需要自己写这个文件,它会在每次运行 npm install 时自动更新。


📄 4.jsconfig.json完整注释版

{
  "compilerOptions": {
    "baseUrl": ".",               // 以当前目录为基础路径
    "paths": {
      "@/*": ["src/*"]           // 设置路径别名 @ = src,便于快速引用组件/资源
    }
  },
  "include": ["src/**/*"]         // 包含 src 文件夹下所有内容
}

这个文件主要用于提升编辑器(如 VSCode)的代码提示、跳转功能,属于“开发体验优化”。


📄 5.vue.config.js(带全注释版本)

module.exports = {
  // 配置开发服务器行为
  devServer: {
    open: true,             // 启动时自动打开浏览器
    port: 8080,             // 自定义端口(默认 8080)
    proxy: {},              // 可设置代理,解决跨域问题
  },

  // 设置打包输出目录
  outputDir: 'dist',        // 构建后的输出目录

  // 静态资源目录(相对 outputDir)
  assetsDir: 'static',

  // 是否在保存时启用 eslint 检查
  lintOnSave: true,

  // 修改 webpack 配置
  configureWebpack: {
    // plugins, resolve.alias, module.rules 可在这里定制
  },

  // 控制 source map 生成(影响构建体积)
  productionSourceMap: false
};

🧠 如果你使用的是 Vite,它没有 vue.config.js,而是 vite.config.js,配置方式完全不同。


📁 6. 组件目录

src/components/

示例说明

一个组件长这样:

<!-- src/components/HelloWorld.vue -->
<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',         // 组件名
  props: {
    msg: String               // 接收从父组件传来的 props
  }
}
</script>

<style scoped>
h1 {
  color: #42b983;
}
</style>

✅ 最后总结

文件/目录 作用
node_modules/ 项目依赖包目录,不手动编辑
public/ 不经 webpack 处理的静态资源目录
src/ 核心源码目录,日常开发都在这里进行
main.js Vue 应用的入口文件
App.vue 根组件,页面从这里开始渲染
components/ 可复用的 Vue 组件目录
.gitignore Git 忽略配置
index.html HTML 模板,Vue 会挂载到
package.json 项目依赖、命令、元信息的配置文件
package-lock.json 锁定依赖版本,防止安装不同包
jsconfig.json 编辑器优化配置,支持路径别名等
vue.config.js Vue CLI 的构建/运行/代理等配置项
README.md 项目说明文档

如果你觉得这篇文章实用,欢迎分享到你的博客平台如 CSDN、掘金、知乎等。

实现一个简单的vue2实现(一)

手把手来实现一个simple-vue2

vue1(2014年)发展到今天已经有11年了,但估计也有很多人也才刚刚开始学习vue3,今天就手把手来学习实现一个简易的vue,目的是来了解vue的底层原理,掰开了揉碎了,方便用户能更快的入手vue,能更快的和和vue达到人码合一的境界。

代码全部托管在simple-vue-app

预览

搭建脚手架

把时间拉回到2014年,使用webpack搭建脚手架。

mkdir simple-vue
cd simple-vue
npm init -y & npm install --save-dev html-webpack-plugin webpack webpack-cli webpack-dev-server

新建webpack.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  mode: 'development',
  devServer: {
    static: './dist',
    port: 3000,
    hot: true
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './index.html',
    }),
  ],
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
    clean: true,
  }
};

新建index.html

<!DOCTYPE html>
<html lang="zh">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>简单vue实现</title>
</head>

<body>
  <div id="app">
    <input v-model="message" />
    <p>{{ message }}</p>
  </div>
</body>

</html>

程序入口

新建src/index.js作为程序入口

新建src/simple-vue.js作为vue的实现

src/simple-vue.js创建一个createApp,作为vue的入口,并返回一个对象,对象中提供一个mounted,参数是一个selector选择器,用来挂载vue渲染的地方。

export function createApp(options) {
  return {
    mount(selector) {}
  }
}

vue组件的定义(约定)

这里的options是vue组件的定义,记住它并理解它,它是vue2的基础,是进阶vue3的基石。

总结一下,vue组件定义就是一个约定的js对象。

{
  data() {
    return {
      message: '你好,世界!',
    }
  },
  methods: {
    sayHello() {
      alert(this.message);
    }
  },
  mounted() {
    alert('组件挂载');
    window.setTimeout(() => {
      this.message = '你好,世界!!!';
    }, 2000)
    window.setTimeout(() => {
      this.sayHello();
    });
  },
  unmounted() {
    alert('组件卸载');
  },
}

接下来我们将导出的createApp导出在放在src/index.js中使用,并传入上面的组件定义。

import { createApp } from './simple-vue';

createApp({
  data() {
    return {
      message: '你好,世界!',
    }
  },
  methods: {
    sayHello() {
      alert(this.message);
    }
  },
  mounted() {
    alert('组件挂载');
    window.setTimeout(() => {
      this.message = '你好,世界!!!';
    }, 2000)
    window.setTimeout(() => {
      this.sayHello();
    });
  },
  unmounted() {
    alert('组件卸载');
  },
}).mount('#app');

可以看到已经有基本的vue组件的样子了,有数据状态、方法、挂载、卸载。

这里的mount('#app')是往index.html<div id="app"></div>上去挂载。

启动命令

这时候可以使用npx webpack serve来启动项目,观察页面可以看到展示了页面原始的样子。

可以将命令维护到package.json中,下次使用npm run dev就可以运行了。

{
  "name": "simple-vue",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "dev": "webpack serve",
    "build": "webpack",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "devDependencies": {
    "html-webpack-plugin": "^5.6.3",
    "webpack": "^5.100.2",
    "webpack-cli": "^6.0.1",
    "webpack-dev-server": "^5.2.2"
  }
}

拆分功能

下面我们的任务分别是

  • 创建vue的实例ctx,方便任何时候调用vue的方法和属性。
  • 创建状态data的双向数据绑定,使用Object.defineProperty的get、set的方式,Proxy的方式等后续再来重写。
  • 实现v-model,简单实现即可,后续再重写成解析AST的方式来绑定
  • 实现事件@click绑定
  • 将模板语法{{ message }}替换成实际的值
  • 实现mounted(mounted)生命周期函数
  • 实现unmounted(beforeDestroy)生命周期函数

注意,以上的实现都只是简单的实现,不是一比一的还原vue,那将是一个大工程,目的只是为了方便实现和理解vue的原理。

创建vue实例,实现data的双向绑定

// 获得挂载的根节点
const el = document.querySelector(selector);
if (!el) {
  return;
}

// 获得状态data
const data = options.data();
// 创建vue实例/上下文,后面的一切都要在ctx上得到具体的应用
// 构建上下文
const ctx = Object.create(reactiveData);
// 将 methods 添加到 ctx,并绑定 this 为 ctx
for (const key in options.methods) {
ctx[key] = options.methods[key].bind(ctx);
}


Object.defineProperty(reactiveData, key, {
  get() {
    return internalValue;
  },
  set(newVal) {
    internalValue = newVal;
    // 触发所有绑定更新
    bindings[key].forEach(updateFn => updateFn());
  }
});

这里reactiveData就是data的双向绑定的对象了,这时候如果更改messaged的值,会调用reactiveData的set函数,如果获取就会调用get函数。

可以在这里练习一下,这个例子简单的实现了当数据发生变更,调用set就可以实时将值渲染到input中。

那相同的,如果input的input事件触发,也可以实时将更改的值应用到data中。 可以在这里练习一下,这个例子简单的实现了文本框是如何触发数据变更的,普通的data.count = ''也是一样的逻辑触发set,set中在触发更新元素内容的逻辑。

reactiveData是实现数据驱动视图更新的核心变量之一,当触发数据变更的重要媒介。

我们在定义个bindings来作为触发更新事件的媒介,把所有发生set时的白变更事件都放到bindings中,用v-mode="状态值的字面量"的字面量来当key,就能实现reactiveData和bindings的绑定了,只要具有相同的key就是对应set和get方法。

那么到编写代码了实现了,将这些功能抽象成通用的逻辑吧。

// 将vue实例ctx中的data覆盖掉,这里应该只有ctx和data两个对象,所以可以使用Object.assign来事件,如果是复杂的具有多层级的结构,则不允许使用如此暴力的方法合并,要单独使用迭代的方式去实现
Object.assign(ctx, proxy);

  /**
  * 对页面挂载节点的内容进行解析,
  * 解析v-model、@click事件等
  * @param {*} node 
  */
  function compile(node) {}

  // 将组件的根节点放进来进行解析
  compile(el);

原理讲解

complie方法稍微有点复杂,我们单独拿出来写,我们要对index.html里的<div id="app"></div>里的元素进行解析,将data.message的数据绑定到input中,还要在p中显示data.message的数据。vue中使用的是AST抽象语法数(abstract syntax tree),将html解析成可以方便编程的js数据结构,关于js数据结构,推荐阅读《学习JavaScript数据结构与算法》这本书。

源码1

<div id="app">
  <input v-model="message" />
  <p>{{ message }}</p>
</div>

AST1

{
  tag: 'div',
  children: [
    {
      tag: 'p',
      children: ['{{ message }}']
    },
    {
      tag: 'input',
      props: {
        'v-model': 'message'
      }
    },
    {
      tag: 'button',
      props: {
        '@click': 'changeMessage'
      },
      children: ['Change']
    }
  ]
}

源码2

<div>
  <p>{{ message }}</p>
  <input v-model="message" />
  <button @click="changeMessage">Change</button>
</div>

AST2

{
  tag: 'div',
  children: [
    {
      tag: 'p',
      children: ['{{ message }}']
    },
    {
      tag: 'input',
      props: {
        'v-model': 'message'
      }
    },
    {
      tag: 'button',
      props: {
        '@click': 'changeMessage'
      },
      children: ['Change']
    }
  ]
}

编译过程和结果大致是这样的,更细致一点会把@click解析成下面这种更方便的结构

{
  event: 'click',
  fn: 'sayHello',
}

社区有很多开源的解析器,但vue是自己开发的解析@vue/compiler-dom@vue/compiler-core,感兴趣可以去npm和vue官方源码的packages中阅读。

vue会先使用@vue/compiler-dom解析成下面的结构

{
  type: 'Element',
  tag: 'button',
  props: [
    {
      type: 'Directive',
      name: 'on',
      arg: { type: 'Expression', content: 'click' },
      exp: { type: 'Expression', content: 'sayHello()' }
    }
  ],
  children: [...]
}

然后使用@vue/compiler-core将上面的VUE专用的AST解析成下的可执行的代码

function render(ctx) {
  return h('button', {
    onClick: () => ctx.say(ctx.name)
  }, '说你好');
}

原理说到这里,但今天不打算走AST这条路,就简单使用原生的js来实现,通过判断dom是否包含属性v-model,将v-model绑定的keymessage获取到。

因为reactiveData.message是响应式的,赋值set和获取值get都能触发对应的set、get事件。那可以将有v-model="状态值的字面量"的组件绑定到reactiveData['状态值的字面量']的get事件,将文本框的input事件绑定到reactiveData['状态值的字面量']的set事件。

// v-model 绑定输入框
if (node.hasAttribute('v-model')) {
  // 获取v-model的字面量
  const key = node.getAttribute('v-model');
  // 初始化文本框的值
  node.value = reactiveData[key];

  // 绑定input的input事件,文本框的值发生变化就要更新reactiveData的值
  node.addEventListener('input', e => {
    reactiveData[key] = e.target.value;
  });

  // 添加更新页面元素的渲染值的方法
  bindings[key].push(() => {
    node.value = reactiveData[key];
  });
}

处理绑定事件

接下来处理解析click点击事件,vue在处理点击事件时依然是使用的它的核心两大解析器,这里就先不用了,简单判断属性名称是以@字符开头的,不管事件名称是啥字符串,一股脑的给元素绑定事件。 还记得我们在组件结果里定义的methods了吗?我们讲他绑定到ctx上了,@事件名称中取到事件名称,然后将有了addEventListener的两大重要参数eventName和fn。直接绑定上去即可。这里不对事件名称做校验,合不合法不管。

这里的bind函数是经典的绑定this的方法,将vue实例ctx绑定给事件。这样在methods中的方法就可以使用this.message = 1this.sayHello()了。

// @click 事件
[...node.attributes].forEach(attr => {
  if (attr.name.startsWith('@')) {
    const eventName = attr.name.slice(1);
    const methodName = attr.value;
    node.addEventListener(eventName, ctx[methodName].bind(ctx));
  }
});

处理模板语法

接下来还需要将响应更改的值渲染到模板语法{{ message }}中,还要将更新事件放到bindings中,我们称之为绑定,和reactiveData一样,使用key来绑定。

这里依然不使用vue的两大核心模板,就简单使用正则来匹配。然后将{{ message }}替换成绑定的值替,可以在这里进行练习。

那为了兼容<div>{{ message }} {{ prefix }}</div>这种情况,实际上肯定不能这么简单,需要匹配所有的字面量,所以需要修改成数组的形式,对每一个模板{{ message }}都进处理。

const raw = node.textContent;
const reg = /{{\s*(\w+)\s*}}/g;
const matches = [...raw.matchAll(reg)];

if (matches.length) {
  const update = () => {
    node.textContent = raw.replace(reg, (_, key) => proxy[key]);
  };
  update();
  matches.forEach(match => {
    const key = match[1];
    if (!bindings[key]) {
      bindings[key] = [];
    }
    bindings[key].push(update);
  });
}

整个逻辑逻辑理完了,接下来整合在一起。

整合逻辑

回到我们的createApp函数中,我们要在mount中将代码的主要逻辑搭建出来。

export function createApp(options) {
  return {
    mount(selector) {
    }
  };
}

首先当然是要将前面几部讲过的步骤整合到mount中,再补充一下mounted和unmounted事件,判断下options上有没有mount和unmounted事件,有的话把mounted直接触发一下。有unmounted事件,就在window的beforeunload事件中触发一下,这里使用call和bind一样,是一个经典的函数调用方式,将当前实例传入,这里和bind不一样,并不是为了使用this.message,而是为了给mounted一个回调的参数。 实际上vue2的onMounted和beforeDestroy并没有回调参数,放到这里存储是出现了bind,顺手拿出来用一下。

而为了更准确的判断哪些元素需要处理,这里对类型进行了判断,node.nodeType === Node.ELEMENT_NODE判断是p、div、text、input等element元素,不是meta、script这些元素。符合这个条件的,才处理v-bind、@click事件。

node.nodeType === Node.TEXT_NODE则是判断元素是一个文本节点<div>文本内容</div>,这是浏览器两大对象之一DOM里面的Node的内容。符合文本节点的,才处理{{ message }}的替换工作。

知道他们是干嘛的,将对应的事件放到对应的位置。一个简单的vue2就实现了。

export function createApp(options) {
  return {
    mount(selector) {
      const el = document.querySelector(selector);
      if (!el) {
        return;
      }

      function compile(node) {
        if (node.nodeType === Node.ELEMENT_NODE) {
          console.log('处理v-model、input、@click、')

          // 递归处理子节点
          [...node.childNodes].forEach(child => compile(child));
        }

        // 处理{{ message }}
        if (node.nodeType === Node.TEXT_NODE) {
        }
      }

      compile(el);

      if (options.mounted) {
        options.mounted.call(ctx);
      }
      window.addEventListener('beforeunload', () => {
        if (options.unmounted) options.unmounted.call(ctx);
      });
    }
  };
}

这里复杂逻辑就在compile中,但我们先把容易的放写进去上去。

export function createApp(options) {
  return {
    mount(selector) {
      const el = document.querySelector(selector);
      if (!el) return;

      const data = options.data();

      // 依赖追踪表,负责在数据变化时通知更新 DOM
      const bindings = {};

      // 响应式对象,它就是组件中你访问的 this.message
      const reactiveData = {};

      // 使用 Object.defineProperty 创建响应式
      Object.keys(data).forEach(key => {
        let internalValue = data[key];

        bindings[key] = [];

        Object.defineProperty(reactiveData, key, {
          get() {
            return internalValue;
          },
          set(newVal) {
            internalValue = newVal;
            // 触发所有绑定更新
            bindings[key].forEach(updateFn => updateFn());
          }
        });
      });

      // 构建上下文
      const ctx = {
        ...options.methods,
        ...reactiveData,
      };

      function compile(node) {
        if (node.nodeType === Node.ELEMENT_NODE) {
          console.log('处理v-model、input、@click、')

          // 递归处理子节点
          [...node.childNodes].forEach(child => compile(child));
        }

        // 处理{{ message }}
        if (node.nodeType === Node.TEXT_NODE) {
        }
      }

      compile(el);

      if (options.mounted) {
        options.mounted.call(ctx);
      }
      if (options.unmounted) {
        window.addEventListener('beforeunload', () => {
          options.unmounted.call(ctx);
        });
      }
    }
  };
}

下面书略了部分代码,但compile函数函数没有省略,这里做的工作就是对html进行解析和绑定,将前面理过的逻辑放进来,

export function createApp(options) {
  return {
    mount(selector) {
      const el = document.querySelector(selector);
      if (!el) return;

      // 省略data

      // 依赖追踪表,负责在数据变化时通知更新 DOM
      const bindings = {};

      // 响应式对象,它就是组件中你访问的 this.message
      const reactiveData = {};

      // 省略使用 Object.defineProperty 创建响应式

      // 构建上下文
      // 构建上下文
      const ctx = Object.create(reactiveData);
      // 将 methods 添加到 ctx,并绑定 this 为 ctx
      for (const key in options.methods) {
        ctx[key] = options.methods[key].bind(ctx);
      }


      function compile(node) {
        if (node.nodeType === Node.ELEMENT_NODE) {
          // v-model
          if (node.hasAttribute('v-model')) {
            const key = node.getAttribute('v-model');
            node.value = reactiveData[key];

            node.addEventListener('input', e => {
              reactiveData[key] = e.target.value;
            });

            bindings[key].push(() => {
              node.value = reactiveData[key];
            });
          }

          // @click
          [...node.attributes].forEach(attr => {
            if (attr.name.startsWith('@')) {
              const eventName = attr.name.slice(1);
              const methodName = attr.value;
              node.addEventListener(eventName, ctx[methodName].bind(ctx));
            }
          });

          [...node.childNodes].forEach(child => compile(child));
        }

        // 插值
        if (node.nodeType === Node.TEXT_NODE) {
          const raw = node.textContent;
          const reg = /{{\s*(\w+)\s*}}/g;
          const matches = [...raw.matchAll(reg)];

          if (matches.length) {
            const update = () => {
              node.textContent = raw.replace(reg, (_, key) => reactiveData[key]);
            };
            update();
            matches.forEach(match => {
              const key = match[1];
              bindings[key].push(update);
            });
          }
        }
      }

      compile(el);

    }
  };
}

留几个思考题

  • 为什么data要定义成函数(import、export的特性)
  • 如何更改成Proxy实现双向绑定
  • watch、computed如何实现
  • 如何绑定keyup、keydown等事件?

🚀 零构建!教你通过 CDN 快速使用 Vue 3(含模块拆分 + Import Maps 实战)

在构建现代 Vue 应用时,我们常用的工具链如 Vite、Webpack 都依赖构建步骤。但其实,Vue 也可以完全不需要构建工具,直接通过

本文将带你系统掌握如何通过 CDN + 原生 ES Module 使用 Vue 3,并解决过程中可能遇到的坑,让你在不依赖任何打包工具的情况下,也能愉快地写 Vue!

🌐 一、最简单的方式:通过 CDN 引入全局版本

首先,我们可以通过

<!-- 引入 Vue 全局构建版本 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

<div id="app">{{ message }}</div>

<script>
  const { createApp, ref } = Vue

  createApp({
    setup() {
      const message = ref('Hello Vue!')
      return { message }
    }
  }).mount('#app')
</script>

适用场景

  • 学习 Composition API;

  • 简单的页面增强;

  • 和后端模板语言(如 JSP、PHP、Django)集成;

  • 原型演示、内网项目或低复杂度页面。

限制

  • 不能使用 .vue 单文件组件(SFC);
  • 没有代码拆分;
  • 仅适合开发环境或轻量级需求。

📦 二、使用 ES 模块版本(推荐)

现代浏览器支持原生模块(

<!-- index.html -->
<div id="app">{{ message }}</div>

<script type="module">
  import { createApp, ref } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'

  createApp({
    setup() {
      const message = ref('Hello Vue with ESM!')
      return { message }
    }
  }).mount('#app')
</script>

优势

  • 支持模块化开发;

  • 更贴近现代 Vue 项目结构;

  • 可拆分组件、复用逻辑;

  • 无需构建工具即可使用 Composition API。

❗注意:一定要加 type="module",否则导入会失败。


🧩 三、使用 Import Maps 优化模块导入路径

在模块开发中,我们常写:

import { createApp } from 'vue'

如果直接在浏览器中运行,浏览器不知道 vue 是什么。这时候就可以用 Import Maps 解决路径映射的问题。

<!-- 使用 importmap 告诉浏览器 'vue' 指向哪 -->
<script type="importmap">
  {
    "imports": {
      "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
    }
  }
</script>

<div id="app">{{ message }}</div>

<script type="module">
  import { createApp, ref } from 'vue'

  createApp({
    setup() {
      const message = ref('Hello Vue via Import Map!')
      return { message }
    }
  }).mount('#app')
</script>

🧠 解释

  • type="importmap" 是一种告诉浏览器模块路径映射的机制;

  • 这样我们就可以像在 Node 项目中一样用简短的模块名 vue。

⚠️ 兼容性提示

  • Import Maps 是新特性,目前主流浏览器都支持(Chrome 89+、Safari 16.4+、Firefox 108+);
  • 老版本浏览器不支持,可通过 polyfill 或 fallback 处理。

🧱 四、实现模块拆分:Vue 模块化开发实践

一个页面内代码写太多会很混乱,Vue 支持将组件拆成多个模块。

✅ 示例结构:

/project/
├── index.html
└── my-component.js

index.html

<script type="importmap">
  {
    "imports": {
      "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
    }
  }
</script>

<div id="app"></div>

<script type="module">
  import { createApp } from 'vue'
  import MyComponent from './my-component.js'

  createApp(MyComponent).mount('#app')
</script>

my-component.js

import { ref } from 'vue'

export default {
  setup() {
    const count = ref(0)
    return { count }
  },
  template: `<button @click="count++">Count is: {{ count }}</button>`
}

🔒 注意事项:

  1. 必须使用 HTTP 协议打开项目

    • 双击 HTML 文件后浏览器使用的是 file:// 协议,ES Module 无法加载;
    • 正确方式:使用本地 HTTP 服务器。
  2. 如何启动本地服务器?

    • 如果你安装了 Node.js,可以直接在项目目录运行:
npx serve .
    • 或者使用 Python:

python3 -m http.server

🔐 五、开发 vs 生产的注意事项

以上例子中引入的都是 Vue 的开发版本(包含完整调试信息,体积大),不适合直接用于生产。

🏭 正确做法:

  • 使用 Vue 的生产构建版本,例如:
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
  • 或使用构建工具(如 Vite)来生成最小化的 JS 文件;

  • CDN 也可以做生产部署,但要控制缓存和依赖版本;

  • 如果只是轻量级使用,可考虑使用官方提供的轻量方案 Petite Vue,类似 Alpine.js,更适合纯静态页面增强。


✨ 小技巧:组件模板语法高亮

当你把 Vue 的 template 写在字符串里时,VS Code 默认不识别语法。你可以:

  1. 安装插件:es6-string-html;
  2. 给模板加注释:
template: /*html*/ `<div>{{ message }}</div>`

这样就能高亮模板内容了,写代码更舒服!


✅ 总结:何时使用哪种方式?

方式 优点 缺点 适用场景
快速、简单、无依赖 无模块、无拆分 Demo、后端集成
支持模块、组合式 API 需 http 服务 原型、现代开发
Import Maps 模块路径更清晰 浏览器兼容性限制 学习、轻量项目
拆分模块文件 项目结构清晰 不支持 file:// 打开 小型真实项目

📚 延伸阅读


如果你觉得这篇文章帮你看懂了 Vue 的 CDN 使用方式,欢迎点赞、收藏、转发分享给其他开发者,让更多人受益。

📦 npm、yarn、pnpm、bun 是什么?有什么区别?哪个更适合你?【全面对比指南】

作为一名前端开发者,你一定听说过 npm、yarn、pnpm,甚至是新晋黑马 bun。它们都是 JavaScript 包管理工具,但到底有什么区别?分别适合哪些场景?本文将带你全面了解这四个工具,帮助你选出最适合你的开发利器。


🎯 什么是包管理器?

在 JavaScript 和 Node.js 生态中,一个项目往往依赖很多第三方库,比如 Vue、React、Lodash 等。**包管理器(Package Manager)**的作用,就是:

  • 安装/卸载这些依赖库;

  • 管理依赖的版本;

  • 构建依赖树;

  • 提供运行脚本(如 npm run dev)等功能。

最早是 npm,后来有了性能更好的 yarn,又有了极致节省空间的 pnpm,最近还杀出了一个全能选手 —— bun。


📌 1. npm:最原始、最广泛的包管理器

  • 全称:Node Package Manager

  • 开发者:Node.js 官方团队

  • 地位:最基础、最广泛使用的工具(Node.js 安装时自带)

  • 命令前缀:npm

✅ 特点

  • 社区最广泛支持;

  • 包管理逻辑清晰,学习资料丰富;

  • 缺点是早期安装速度较慢、存在重复安装、依赖树冗余等问题;

  • 从 npm v7 开始改进了很多(比如支持 workspaces)。

🚀 常用命令示例

npm install          # 安装项目依赖
npm install vue      # 安装 vue 包
npm uninstall vue    # 删除 vue 包
npm run serve        # 执行 package.json 中的脚本

📌 2. yarn:Facebook 出品,解决 npm 的痛点

  • 开发者:Facebook

  • 诞生原因:为了解决 npm 安装速度慢、锁文件混乱、网络不稳定等问题

  • 命令前缀:yarn

✅ 特点

  • 更快的安装速度(缓存机制);

  • 使用 yarn.lock 文件,锁定版本更清晰;

  • 原生支持 monorepo(多包项目);

  • 更优雅的输出格式,更友好的用户体验;

  • v2 开始重构为 Plug’n’Play 模式(默认禁用 node_modules)。

🚀 常用命令示例

yarn                # 安装所有依赖
yarn add axios      # 添加依赖
yarn remove axios   # 删除依赖
yarn dev            # 运行脚本

📌 3. pnpm:磁盘空间终结者,速度与规范兼得

  • 全称:Performant npm

  • 开发者:Zoltan Kochan 等开源作者

  • 命令前缀:pnpm

✅ 特点

  • 使用符号链接和内容寻址(类似 git)机制,实现依赖复用;

  • 同一包只在硬盘上存一份,极大节省 node_modules 空间;

  • 安装速度极快;

  • 结构更规范、更严格,不容易出现“依赖地狱”;

  • 完全兼容 npm 的语法。

🚀 常用命令示例

pnpm install           # 安装依赖
pnpm add vue           # 安装 vue
pnpm remove vue        # 删除 vue
pnpm run dev           # 运行脚本

🎁 小彩蛋:pnpm 能用来一键初始化 Vite 项目

pnpm create vite

📌 4. bun:下一个全能型选手(包管理 + JS 运行时 + 构建工具)

  • 开发者:Jarred Sumner

  • 诞生时间:2022 年

  • 核心目标替代 Node + npm/yarn + webpack 等工具链

  • 命令前缀:bun

✅ 特点

  • 内置 包管理器、JS 执行器、构建器 三合一;

  • 使用 Zig 编写,性能极高;

  • 安装依赖比 npm 快十几倍;

  • 启动脚本更快、内存占用更少;

  • 仍在快速更新,生态在逐步扩展中。

🚀 常用命令示例

bun install          # 安装依赖
bun add vue          # 安装 vue
bun remove vue       # 删除 vue
bun run dev          # 运行脚本
bun index.ts         # 直接执行 TypeScript 文件

⚠️ 注意:bun 虽然很强,但目前生态还不如 npm/yarn/pnpm 完善,适合技术探索者或追求极致性能的场景。


📊 对比总结表格(最重要)

功能 / 特性 npm yarn pnpm bun
出现时间 最早 后于 npm 后于 yarn 最晚
安装速度 一般(新版改善) 较快(有缓存) 极快(复用依赖) 最快(Zig 编写)
节省磁盘空间 ✅ 是 ✅ 是
锁文件 package-lock.json yarn.lock pnpm-lock.yaml bun.lockb
原生 monorepo 支持 有(v7+) ✅ 是 ✅ 是 正在完善
生态稳定性 ✅ 最成熟 ✅ 成熟 ✅ 稳定 🚧 新但潜力大
是否默认带 node_modules ✅ 是 ✅ 是 ✅ 是(使用硬链接) ❌(使用虚拟 FS)
推荐人群 初学者 / 通用 跨团队协作 追求性能与规范 高性能探索者

✅ 开发建议(如何选择?)

你是谁 推荐选择
刚接触前端的新手 ✅ npm(默认自带,文档最多)
想要更快构建、更稳定依赖 ✅ yarn 或 pnpm
大型项目、多包管理(monorepo) ✅ pnpm
技术极客、极限性能控 ✅ bun

✍️ 写在最后

JavaScript 生态非常活跃,每一个包管理器都有其适合的使用场景。现在你已经了解了它们之间的区别,可以根据自己的需求合理选择工具,为开发提速、为项目减负。


如果你觉得这篇文章对你有帮助,欢迎点赞、收藏并转发给正在学前端的朋友们 ❤️

别再用错了!一分钟让你区分 useRef 和 useState

useRefuseState 都是 React 的 Hook,用于在函数组件中保存数据,但二者的用途和行为有明显区别:

  • useState

    • 用于管理需要触发重新渲染的状态数据
    • 状态变化会导致组件重新渲染,视图会更新。
    • 适合保存会影响界面显示的变量(如输入框内容、组件显隐状态等)。
    • 状态更新是异步的,在同一次 render 中无法立即获取最新值。
  • useRef

    • 用于保存不会引起视图重新渲染的可变数据
    • 适合存储 DOM 引用、定时器 ID、前一状态、临时变量等。
    • 数据更新不会触发组件的 rerender,更像是在整个组件生命周期内都可变的“容器” (ref 对象,current 属性持久有效)。
    • 状态更新是同步的,可在当前 render 流程内立即获取新值。
区别 useState useRef
渲染影响 数据变化会引起组件重新渲染 数据变化不会引起组件重新渲染
数据存储方式 返回 [状态, 设置函数] 返回 ref 对象(通过 .current 获得和设置值)
用途 存放会影响界面显示的状态数据 存放 DOM、定时器、可变临时数据,不影响界面
更新方式 setState 异步更新,无法立即获得最新值 ref.current 同步更新,可立即获得
常用场景 交互状态、表单输入、显隐控制等 获取 DOM、缓存旧值、标记、临时存储中间量等

选择建议

  • 如果数据变化影响 UI,需用 useState
  • 如果只需在渲染间持久保存某个数据且不会影响 UI,用 useRef

注意事项

  • ref.current 的改变不建议作为 useEffectuseMemo 等 hook 的依赖项;
  • useRef 不会参与 React 的响应式更新机制。

🧭 Vue CLI 项目运行时自动打开浏览器的实现方法【保姆级教程】

很多前端小伙伴在开发 Vue 项目时,希望像 Vite 一样,运行 npm run serve 后能自动打开浏览器,而 Vue CLI 默认并不会这么做。那么我们如何实现这一效果呢?

本文将从零讲解如何配置 Vue CLI 项目,让它在运行时自动打开浏览器,适合零基础读者,确保一步不落照着做就能成功!


✨ 方法一:使用

vue.config.js

开启浏览器自动打开

Vue CLI 项目底层使用的是 webpack-dev-server,它本身提供了 open 选项可以实现该功能。

✅ 操作步骤如下:

第一步:确认你的项目是 Vue CLI 项目

如果你还没创建项目,先执行:

npm install -g @vue/cli
vue create my-vue-app
cd my-vue-app

然后执行:

npm run serve

确认可以正常启动。


第二步:在项目根目录创建

vue.config.js

如果你已经有这个文件,直接编辑即可。如果没有,执行以下命令创建:

touch vue.config.js

第三步:加入自动打开浏览器配置

// vue.config.js

module.exports = {
  devServer: {
    open: true, // 启动开发服务器时自动打开默认浏览器
  }
};

(可选)指定打开的页面或浏览器(进阶用法)

module.exports = {
  devServer: {
    open: {
      target: ['http://localhost:8080'], // 指定打开地址
      app: {
        name: 'Google Chrome' // 指定浏览器,如 'Google Chrome', 'firefox', 'Safari'
      }
    }
  }
};

💡 注意:浏览器名称请根据你的操作系统来填写,不同系统名称不同。比如:

  • macOS: 'Google Chrome'、'Safari'

  • Windows: 'chrome'、'firefox'

  • Linux: 取决于你安装的浏览器


第四步:重启项目,测试效果

npm run serve

此时,浏览器应该会自动打开 http://localhost:8080。


🔧 方法二:使用

open-browser-webpack-plugin

插件(不推荐但可选)

如果你使用的是更复杂的自定义 webpack 配置,也可以使用专用插件来控制浏览器行为。

⚠️ 本方法不适用于 Vue CLI 默认配置,通常只有在 完全手动配置 Webpack的项目中才推荐使用。

安装插件

npm install --save-dev open-browser-webpack-plugin

修改

vue.config.js

const OpenBrowserPlugin = require('open-browser-webpack-plugin');

module.exports = {
  configureWebpack: {
    plugins: [
      new OpenBrowserPlugin({ url: 'http://localhost:8080' })
    ]
  }
};

⚠️ 注意:此插件对 Vue CLI 4+ 的支持不够理想,推荐优先使用上面方法一的 open: true 配置。


🧪 常见问题排查

问题 解决办法
启动项目没有自动打开浏览器 确保你正确配置了 vue.config.js 且在项目根目录
配置了 open: true 但没效果 尝试使用 open: { target: [], app: {} } 方式明确指定浏览器
不知道默认端口 默认是 8080,除非你在 .env 或 vue.config.js 中改过
报错 Cannot find module 'vue-cli-service' 项目不是 Vue CLI 创建的,请确认是否使用 vite

📦 最终完整示例(推荐配置)

// vue.config.js

module.exports = {
  devServer: {
    open: {
      target: ['http://localhost:8080'],
      app: {
        name: 'Google Chrome' // 根据你实际使用的浏览器填写
      }
    },
    port: 8080
  }
};

✅ 总结

配置方式 是否推荐 说明
vue.config.js 中设置 open: true ✅ 推荐 简洁高效,兼容性好
使用 open-browser-webpack-plugin 插件 ❌ 不推荐 插件老旧,对 Vue CLI 支持不完善

从 Vue CLI 到 Vite:逐步指南

一、Vue CLI vs Vite

1. Vue CLI(Vue Command Line Interface)

Vue CLI 是官方提供的一个命令行工具,用于快速搭建 Vue.js 项目。它通过图形化界面或命令行选项,允许开发者轻松配置项目结构、插件和依赖项。Vue CLI 使用 Webpack 作为其默认的打包工具。

2. Vite

Vite 是由 Vue.js 的作者尤雨溪开发的一个新的前端构建工具。与传统的构建工具如 Webpack 不同,Vite 利用了现代浏览器对 ES 模块的支持来提供极速的服务启动时间和即时的模块热更新(HMR)。这使得开发体验更为流畅。

3. 主要区别

  • 启动速度:Vite 因为采用了原生 ES 模块进行开发服务器的启动,所以在大型项目中比 Vue CLI 更快。
  • 热模块替换(HMR):Vite 提供了更高效的 HMR 实现,使得在保存文件后几乎可以立即看到更改效果。
  • 生态系统:Vue CLI 拥有一个成熟且丰富的插件生态,而 Vite 正在快速发展中,逐渐积累自己的插件库。

二、使用 Vue CLI 创建并运行项目

  1. 安装 Vue CLI

    npm install -g @vue/cli
    
  2. 创建新项目

    vue create my-vue-app
    

    根据提示选择需要的功能和配置。

  3. 进入项目目录

    cd my-vue-app
    
  4. 运行开发服务器

    npm run serve
    
  5. 访问应用 打开浏览器访问 http://localhost:8080/

三、使用 Vite 创建并运行项目

  1. 安装 Node.js 和 npm 确保已安装最新版本的 Node.js 和 npm。

  2. 创建新项目

    npm init vite@latest my-vite-app --template vue
    cd my-vite-app
    
  3. 安装依赖

    npm install
    
  4. 修改 package.json 添加自动打开浏览器功能vite.config.js 中添加:

    export default {
      server: {
        open: true,
      },
    };
    
  5. 运行开发服务器

    npm run dev
    
  6. 查看结果 访问 http://localhost:5173/ 查看你的应用。

四、常见问题及解决方案

  • 重复键错误:确保 package.json 文件中的每个键都是唯一的,避免出现重复定义的问题。
  • JSON 格式错误:不要在 JSON 文件中添加注释,保证格式正确。
  • 停止服务:使用快捷键 Ctrl + C(Windows/Linux)或 Cmd + C(macOS)停止正在运行的服务。

让trae、kiro、kimi k2等15个AI工具基于相同提示词做一个页面,看看谁牛逼谁拉跨

最近 AI coding 工具卷的飞起,隔三差五的出一个新的,也不知道到底谁真的牛逼,谁又是吹的牛逼。 这几天刚好打算给公司的项目做一个入口页,那就把他们都拉出来基于同一个提示词实现这个入口页。

以下所有页面都是一次对话且未经修改的产物,除了wrap,我用了4次对话才成功。

先来看对非程序员最友好的在线平台

bolt.new

v0.dev

lovable.dev

lovable.png如果你想体验,小标题就是他们的网址。

再看看国产开源大模型 Kimi k2、DeepSeek V3还有刚刚发布的Qwen3—Coder

cline + Kimi k2

一开始用 claude code 作为底座,产出的结果惨不忍睹,而且输出特别慢,所以改为 cline 进行测试。

cline + DeepSeek V3

Qwen3-Coder

image.png

Trae

普通模式

Solo 模式

Kiro

这是亚马逊内测中跟cursor、tare一个赛道的 AI IDE
下载地址:www.techspot.com/downloads/7…

vibe 模式

spec 模式

Kiro + vibe.jpg

必须要喷,从需求确认到设计文档再生成任务,最终实施开发。勤勤恳恳干了快半小时,结果布局都没搞明白,鼠标移到卡片上的动画变成了鬼畜的闪烁。

cursor

windsurf + gemini 2.5 pro

augment code

vscode 和 jetbrains 都可以安装使用的插件。 也是我现在用的主力,没体验过的推荐免费体验14天看看。

claude code

开通野卡再开通claude code,花了230,第二天给我号封了。

好在成功退款了。

gemini cli

wrap + claude 4 opus

都用上opus模型了,还是花了4次对话,才成功产出。还是老老实实调用终端命令调用比较适合你。

个人观点

kimi k2和deepseek v3 远远没有达到我的预期,如果有其他选择,就不要浪费时间去用这两了

trae的效果却是超越我预期的,整体配色、布局、微动效都处理的深得我心,所以最终也选择了trae的版本

如果你不是程序员但是想做一个自己的简单网站,看起来,v0.dev在界面设计上是最好的选择。当然bolt.new也不差,萌芽育儿的原型就是用bolt.new做的。

claude code 不管是官方API还是第三方API,跟其他AI IDE生成的都不属于同一梯队。我把基座改成cline后,同样的模型,产出质量提升的非常明显。

gemini cli 还是留给非编程任务吧,编程能力挺一般的,其他方面都很牛,期待gemini推一个为coding优化的模型出来。

trae的solo模式和kiro的spec 类似的流程:确认需求、设计文档、制定计划、实施开发。按理说应该很强,然而实际输出却正好相反,再给这个模式多点进化的时间吧,也可能只是不适合vibe的方式

我最终选择在 trae(普通模式)的基础上进行调整优化。

image.png

如果你想了解我的提示词,我贴个飞书链接:**g2y8m6b26g.feishu.cn/wiki/K8Oyww…

谢谢你看到这里。如果你跟我一样对AI充满兴趣,欢迎关注我的公众号,让我们共赴下一场思维与行动的跃迁:

qrcode_for_gh_03775f02416e_430.jpg

国外疯传的 React UI 动效组件库!Vue3 版正式发布!

“这可能是 2025 年最值得收藏的 UI 动效果组件神器。”

React 版在 Twitter 上被疯狂安利,现在 Vue 开发者终于等到了!”

7.gif

🔥 1 周 3 万星,它到底火在哪?

过去一个月,如果你刷过 Twitter / X,大概率见过 React Bits

1.gif

  • 一条「Splash Cursor」10Demo 视频,播放量破 200 万
  • 开发者 @syskey_dmg 直呼**「最艺术的 UI 库」**;
  • @gregberge_ 把它列入**「年度 10 大 React 工具」**。

核心卖点一句话:90+ 个复制即用的动画组件,全部 MIT 免费,支持 CSS / Tailwind 一键切换

✅ Vue 3 官方移植版来了!名字就叫 Vue Bits

作者亲自下场,用 一周时间 完成了 Vue 3 的 1:1 移植。

2.gif

  • 60+ 组件先行上线,每周同步新增
  • 技术栈:Vue 3 + TypeScript + Tailwind
  • 使用方式与 React 版保持一致,零学习成本。

🚀 3 步把动画搬进你的 Nuxt / Vue 项目

  • 安装 CLI(一次即可)
npm i -g jsrepo
  • 拉取任意组件
npx jsrepo add vue-bits/components/GradientButton
  • 直接 <template> 使用
<GradientButton colorFrom="#ff0080" colorTo="#7928ca">
   Get Started
</GradientButton>

不需要额外依赖,不需要复杂配置,复制即用。

先睹为快:5 个必玩特效

  • Splash Cursor
    鼠标所到之处拖出彩色墨渍,像水波一样扩散,瞬间提升页面质感。

3.gif

  • Text Scramble
    字符像黑客终端般高速随机重组,最后定格成你想要的文案,吸睛指数爆表。

4.gif

  • Profile Card
    3D 卡片沿椭圆轨道自动旋转,滑入滑出带惯性,科技感满满。

5.gif

  • Hyperspeed Background
    赛博隧道式穿梭背景,滚动页面时产生无限透视拉伸,沉浸感极强。

6.gif

  • Infinite Menu
    圆形按钮点击后弹性分裂成多个子项,黏连变形再聚合,萌且丝滑。

8.gif

📦 与 React 版差异速查

维度 React Bits Vue Bits
核心框架 React 18 Vue 3
组件数量 90+ 60+(持续同步)
安装方式 jsrepo add reactbits/... jsrepo add vue-bits/...
主题切换 CSS ↔ Tailwind 一键 完全一致

📣 社区声音

“从 React 切到 Nuxt,最舍不得的就是 React Bits。现在 Vue Bits 来了,完美衔接!”
—— @littlesticks(Nuxt 核心团队成员)

“做 SaaS 落地页,UI 动效 1 小时搞定。感谢开源!”
—— @makwanadeepam

🏁 立即体验

把酷炫动画带进你的下一个项目,只需要一次复制粘贴。

Star 收藏,永远不迷路!

当图片消失时:静态资源加载失败的多级降级实战方案

某个边缘CDN节点故障导致30%用户商品图片加载失败,直接导致转化率下降15%。

痛点场景:电商详情页的图片雪崩危机

用户正在浏览商品详情页,突然看到这样糟糕的场景:

// 典型商品详情组件结构
const ProductDetail = ({ product }) => {
  return (
    <div className="product-page">
      <h2>{product.name}</h2>
      <div className="gallery">
        {product.images.map(img => (
          <img key={img.id} 
               src={img.cdnUrl}  // 🔍 故障点CDN可能不可用
               alt={product.name} 
          />
        ))}
      </div>
      {/* 其他关键内容 */}
    </div>
  )
}

突显的核心问题

  1. 业务痛点:图片加载失败导致用户放弃购买
  2. 技术风险:单点故障(CDN)可引发页面功能雪崩
  3. 体验漏洞:浏览器默认的"图片破损"图标严重影响用户体验

多级降级解决方案设计

基于"渐进式优雅降级"理念,我们设计五级防御体系:

// 图像加载器组件(核心降级逻辑)
function ResilientImage({ src, alt, fallbacks = [] }) {
  const [currentSrc, setCurrentSrc] = React.useState(src)
  
  // 1. 主CDN加载失败处理
  const handleError = (e) => {
    // 🔍 决策点1:优先尝试备用CDN
    if (fallbacks.length > 0) {
      setCurrentSrc(fallbacks.shift())
      return
    }
    
    // 🔍 决策点2:无备用时启用占位图系统
    e.target.onerror = null // 防止循环报错
    applyPlaceholderStrategy(e.target)
  }

  // 多级占位策略
  const applyPlaceholderStrategy = (imgEl) => {
    // 策略1:LQIP(低质量图像占位)
    if (imgEl.dataset.lqip) {
      imgEl.src = imgEl.dataset.lqip
      return
    }
    
    // 策略2:CSS渐变占位(仅需50字节)
    imgEl.outerHTML = `
      <div class="gradient-placeholder" 
           aria-label="${alt}加载失败"
           style="background:linear-gradient(120deg,#f0f0f0 25%,#e0e0e0 50%,#f0f0f0 75%)">
      </div>
    `
  }

  return <img src={currentSrc} alt={alt} onError={handleError} />
}

// 实际业务调用
<ProductImage 
  src="https://cdn1.example.com/prod_123.jpg"
  fallbacks={[
    'https://backup-cdn.example.com/prod_123.jpg',
    'https://storage.oss.com/prod_123.jpg'
  ]}
  data-lqip="data:image/webp;base64,UklGRh4A..." 
/>

核心逻辑逐行解析

  1. 状态管理currentSrc追踪当前实际加载地址
  2. 错误捕获onError事件捕获加载失败事件
  3. 备用源降级:自动切换到预设的备选CDN(最多3级)
  4. 占位策略:先尝试展示低质量预览图(LQIP),失败后转CSS渐变
  5. DOM替换:彻底失败时用div替代img元素避免破损图标

深层原理:降级机制的三层剖析

表面层:用户感知体验

graph TD
    A[加载主CDN] -->|失败| B[尝试备用CDN1]
    B -->|失败| C[尝试备用CDN2]
    C -->|失败| D[启用LQIP]
    D -->|失败| E[CSS渐变占位]
    E -->|最终失败| F[ALT文本提示]

底层机制:浏览器资源加载过程

sequenceDiagram
    participant 浏览器
    participant DOM
    participant 网络层
    
    DOM->>浏览器: 创建<img>发起请求
    浏览器->>网络层: HTTP请求CDN资源
    网络层-->>浏览器: 响应404/超时
    浏览器->>DOM: 触发onError事件
    DOM->>降级逻辑: 执行handleError
    降级逻辑->>DOM: 更新src或替换节点

设计哲学:优雅降级的核心理念

层级 策略 哲学原则
L1:主CDN 高性能交付 黄金路径最优体验
L2:备用CDN 地理容灾 冗余消除单点故障
L3:LQIP占位 内容保真 用户认知连续性
L4:CSS占位 功能可用 最小代价保功能
L5:ALT文本 可访问保障 残障用户可理解

方案对比:主流降级策略性能分析

在百万级PV电商平台实测数据:

策略 成功率 首屏时间 JS体积增加 兼容性
纯事件监听 97.2% 1.8s 0KB IE9+
Service Worker拦截 99.5% 1.5s 18KB 现代浏览器
本文五级降级 99.98% 1.6s 3.2KB IE10+
全平台Polyfill 98.7% 2.1s 12KB IE6+

工程化扩展:企业级部署方案

Webpack生产环境配置

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpe?g|webp)$/,
        use: [
          {
            loader: 'responsive-loader',
            options: {
              // 🔍 生成LQIP占位符
              placeholder: true,
              placeholderSize: 20 // 超小尺寸预览
            }
          },
          {
            loader: 'image-cdn-loader',
            options: {
              primary: 'https://cdn1.example.com/[path]',
              fallbacks: [
                'https://backup1.example.com/[path]',
                'https://object-storage.example.com/[path]'
              ]
            }
          }
        ]
      }
    ]
  }
}

可复用Nginx降级配置

# CDN故障自动回退方案
server {
  location ~* \.(jpg|jpeg|png|webp)$ {
    # 🔍 三级回退策略
    proxy_pass https://main-cdn.com$uri;
    proxy_intercept_errors on;
    
    error_page 404 500 502 503 504 = @img_fallback;

    # 设置快速失败(避免阻塞)
    proxy_connect_timeout 1s;
    proxy_read_timeout 2s;
  }

  location @img_fallback {
    # 优先尝试备份CDN
    proxy_pass https://backup-cdn.com$uri;
    
    # 二次回退到OSS
    error_page 404 500 502 503 504 = @oss_fallback;
  }

  location @oss_fallback {
    # 最后回源到自有存储
    proxy_pass https://storage.example.com$uri;
    
    # 仍失败则返回占位图
    error_page 404 500 502 503 504 = /placeholders/$1;
  }
}

环境适配

  • 现代浏览器:支持webp格式自动切换
  • 老旧设备:自动降级为jpeg占位
  • 国内环境:CDN回退需遵循ICP备案要求

举一反三:多场景降级策略

1. 关键字体加载失败

// 字体加载监控
document.fonts.load('1em MainFont').then(() => {
  document.documentElement.classList.add('fonts-loaded')
}, () => {
  // 🔍 降级到系统字体
  document.documentElement.classList.add('fonts-fallback')
})

/* CSS备用方案 */
.fonts-fallback body {
  font-family: -apple-system, BlinkMacSystemFont, sans-serif;
  /* 启用备用排版方案 */
  letter-spacing: 0.03em;
}

2. 第三方脚本崩溃

<!-- 支付SDK动态加载 -->
<script>
window.paymentSDKReady = () => {
  // 正常初始化
}
</script>

<script src="https://payment-sdk.com/v3" 
        onerror="loadLocalFallback()"></script>

<script>
function loadLocalFallback() {
  // 🔍 加载备用支付流程
  const script = document.createElement('script')
  script.src = '/static/payment-fallback.js'
  document.body.appendChild(script)
}
</script>

3. CSS资源阻塞降级

<!-- 关键CSS内联 -->
<style>
/* 首屏核心样式 */
</style>

<!-- 异步加载完整CSS -->
<link rel="preload" href="main.css" as="style" onload="this.rel='stylesheet'">
<noscript>
  <!-- 🔍 无JS环境降级 -->
  <link rel="stylesheet" href="main.css">
</noscript>

<script>
// 加载失败转备用CDN
document.querySelector('link[rel="preload"]').onerror = function() {
  const link = document.createElement('link')
  link.rel = 'stylesheet'
  link.href = 'https://backup-cdn.com/main.css'
  document.head.appendChild(link)
}
</script>

避坑指南:静态资源降级黄金法则

  1. 避免雪崩效应:单一资源失败不应阻断核心功能

    // 错误示例:图片加载失败阻塞关键操作
    getProductData().then(data => {
      preloadImages(data.gallery).then(renderPage) // 风险点
    })
    
  2. 设置多重熔断

    // 资源加载超时控制
    function loadWithTimeout(url, timeout = 3000) {
      return Promise.race([
        fetch(url),
        new Promise((_, reject) => 
          setTimeout(reject, timeout)
        )
      ])
    }
    
  3. 监控上报体系

    // 资源错误全局监听
    window.addEventListener('error', e => {
      if (e.target.tagName === 'IMG') {
        analytics.send('IMG_LOAD_FAIL', {
          src: e.target.src,
          timestamp: Date.now()
        })
      }
    }, true) // 捕获阶段
    

小程序双线程架构:为什么需要两个线程才能跳舞?

微信小程序在滚动浏览商品列表的同时能实时更新库存数据,却从未卡顿。它是如何做到的?答案就藏在双线程架构的设计哲学中!

真实场景:电商购物车的性能困境

一个促销日,用户在快速滚动浏览商品列表:

// 购物车组件核心逻辑
Page({
  data: { items: [...] },
  
  onScroll(e) { // 📜 高频滚动事件
    // 🔍 核心性能点:频繁计算滚动位置
    this.calculateVisibleItems(e.detail.scrollTop)
    
    // 🔍 业务耦合点:滚动时更新促销信息
    this.checkFlashSale()
  },
  
  updateCart(item, count) { // 🛒 关键操作
    // 🔍 危险操作:直接修改界面数据
    const newItems = this.data.items.map(i => 
      i.id === item.id ? {...i, count} : i
    )
    
    this.setData({ items: newItems }) // 🚧 潜在性能瓶颈
    this.calculateTotal()
  }
})

痛点暴露

  1. 高频滚动事件阻塞API响应(用户点击"结算"延迟3秒)
  2. 频繁数据更新导致页面抖动(Android低端机明显)
  3. 业务逻辑耦合导致代码维护困难

解决方案:双线程模型的精妙设计

小程序创造性地引入渲染层(WebView线程)逻辑层(Worker线程) 的分离架构:

graph LR
  A[用户交互事件] --> B[渲染层]
  B -->|事件传输| C[逻辑层]
  C -->|数据变更| D[虚拟DOM Diff]
  D -->|指令| B[渲染层更新]

在购物车优化中实施线程分离:

// 逻辑层(独立Worker线程)
App({
  cartStore: {},
  
  // 🔍 数据计算与业务逻辑
  updateItemCount(itemId, count) {
    const item = this.cartStore.items.find(i => i.id === itemId)
    if (item) item.count = Math.max(0, count)
    
    // 🔄 通过setData跨线程通信
    this.globalData.bus.emit('cartUpdate', {
      type: 'ITEM_COUNT_CHANGE',
      itemId,
      count
    })
  }
})

// 渲染层(WebView线程)
Component({
  lifetimes: {
    attached() {
      // 🔍 事件总线监听
      getApp().globalData.bus.on('cartUpdate', (msg) => {
        this.handleUpdate(msg) // 💡 轻量消息传递
      })
    }
  },
  
  handleUpdate(msg) {
    if (msg.type === 'ITEM_COUNT_CHANGE') {
      // 🎯 仅更新必要元素
      this.setData({
        [`items[${msg.itemId}].count`]: msg.count
      })
    }
  }
})

关键优化点

  1. 将耗时的购物车计算移至逻辑线程
  2. 通过消息总线机制减少跨线程数据量
  3. 使用路径更新精准重绘组件

深度剖析:双线程架构的三层解析

第一层:表面交互(用户可感知)

功能 单线程 双线程 优势
滚动流畅度 ≤45 FPS ≥58 FPS 40%↑
点击响应 200-500ms 80-120ms 3倍↑
内存占用 ~180MB ~120MB 33%↓

第二层:底层机制(架构实现)

sequenceDiagram
    participant 用户
    participant 渲染层
    participant 逻辑层
    participant Native
    
    用户->>渲染层: 滑动列表
    渲染层->>逻辑层: postMessage('scroll')
    逻辑层->>逻辑层: 计算可见区域
    逻辑层->>渲染层: setData({ visible: [...] })
    渲染层->>渲染层: 渲染更新
    用户->>逻辑层: 点击"加入购物车"
    逻辑层->>Native: wx.request()
    逻辑层->>渲染层: setData({ cartCount })
    Native-->>逻辑层: 返回数据

核心通信机制

  1. setData():数据序列化为JSON(限制大小<256kb)
  2. evaluateJavascript():逻辑层执行渲染层脚本
  3. 事件通道:封装为WebSocket长连接(iOS WKWebView)

第三层:设计哲学(为什么选择双线程?)

安全沙箱设计

// ❌ 被禁止的DOM操作
document.getElementById('cart').innerHTML = ''
wx.createSelectorQuery().select('.cart').remove()

// ✅ 唯一安全的更新方式
this.setData({ showCart: false })

性能隔离优势

"将CPU密集型任务(逻辑层)与渲染密集型任务(渲染层)分离,如同让舞池中的舞者各司其职"

逻辑层 渲染层
主任务 业务逻辑 UI渲染
耗时操作 API请求 Canvas渲染
阻塞影响 不导致页面卡顿 不阻断事件处理

实战扩展:多线程优化策略

1. 跨线程通信优化

// 🔍 高效数据更新模式
// 坏:传输整个列表
this.setData({ items: newItemsArray }) 

// 好:精确路径更新
this.setData({
  'items[2].price': 99,
  'items[5].stock': 0
})

// 最佳:批量更新
const updatePaths = {}
changedItems.forEach(item => {
  updatePaths[`items[${item.id}]`] = item
})
this.setData(updatePaths)

2. WebWorker任务分解

// 在逻辑层创建Worker
const worker = wx.createWorker('workers/cart.js')

// 分解计算任务
worker.postMessage({
  type: 'calculateTotal',
  items: this.cartItems
})

worker.onMessage(res => {
  if (res.type === 'result') {
    this.setData({ total: res.total })
  }
})

// workers/cart.js 内容
worker.onMessage(res => {
  if (res.type === 'calculateTotal') {
    const total = res.items.reduce((sum, item) => 
      sum + item.price * item.count, 0)
    worker.postMessage({ type: 'result', total })
  }
})

线程配置方案(可复用)

// app.json 线程配置(微信小程序)
{
  "workers": "workers", 
  "requiredBackgroundModes": ["audio"],
  "rendererOptions": {
    "skyline": {
      "defaultDisplayBlock": true,
      "disableABExperimental": true 
    }
  }
}

环境适配说明

  • iOS:WKWebView多线程默认启用
  • Android:需开启硬件加速(在manifest中配置)
  • 开发工具:勾选"开启多线程编译"

举一反三:多线程场景扩展

1. 实时数据仪表盘系统

// 独立线程处理WebSocket数据流
const ioWorker = wx.createWorker('workers/socket.js')

ioWorker.postMessage({
  cmd: 'connect',
  url: 'wss://live.example.com'
})

ioWorker.onMessage(res => {
  if (res.event === 'dataUpdate') {
    this.setData({ metrics: res.data })
  }
})

2. 图像处理工作流

// 图像处理线程
const imgWorker = wx.createWorker('workers/image.js')

Page({
  processImage(path) {
    imgWorker.postMessage({
      action: 'compress',
      path,
      quality: 0.8 
    })
  }
})

// workers/image.js
worker.onMessage(async (res) => {
  if (res.action === 'compress') {
    const resized = await compressImage(res.path, res.quality)
    worker.postMessage({ result: resized })
  }
})

3. 游戏状态同步引擎

// 双线程游戏架构
// 逻辑线程:physics.js
function updateGameState() {
  calculatePhysics()
  detectCollisions()
  broadcastStateUpdate()
}

// 渲染线程:graphics.js
onMessage('stateUpdate', (state) => {
  renderPlayers(state.players)
  updateScore(state.score)
})

避坑指南:多线程开发经验

跨线程禁忌

  1. 禁用大对象传输(>200KB时序列化成本显著)
// 错误:传输大文件
this.setData({ bigImageData: buffer })

内存泄漏防范

// Worker使用后及时销毁
onUnload() {
  this.worker.terminate() // 🔍 关键回收点
}

调试技巧

// 开启多线程调试
// app.json
{
  "debugOptions": {
    "enableWorkerThread": true
  }
}

小程序的双线程架构如同精心设计的交响乐团:逻辑层是指挥(统筹协调业务逻辑),渲染层是弦乐组(专注表现输出),通过精准的事件通道(指挥棒)实现和谐演出。这种分离使小程序在安全沙箱中依然能跳出流畅的交互舞蹈。

封装个组件怎么连ref都拿不到?React你礼貌吗?

React.forwardRef

在 React 的组件化开发中,forward是一个非常重要且实用的 API,它主要用于解决高阶组件(HOC)或其他组件包装方式中,原始组件的属性和引用传递问题。

一、React.forwardRef的基本概念

在正常情况下,ref属性不能像普通属性一样通过组件传递,因为ref并不是组件 props 的一部分。而React.forwardRef则打破了这一限制,让组件可以将ref转发给内部的某个子元素。React.forwardRef是 React 提供的一个函数,它允许组件接收并向子组件传递ref。

其基本语法如下:


const ForwardedComponent = React.forwardRef((props, ref) => {
  return <div ref={ref} {...props} />;
});

const App(){
    const myRef = useRef(null)
    return (
        <ForwardedComponent ref={myRef}/>
    )
}

这里,使用forwardRef接收一个渲染函数,该函数接收props和ref两个参数,并返回一个 React 元素。通过这种方式,当外部组件使用ForwardedComponent并传递ref时,这个ref会被传递到内部的div元素上。

二、React.forwardRef的实现原理

从本质上来说,React.forwardRef创建了一个特殊的组件类型。当 React 渲染这种组件时,会将ref作为第二个参数传递给渲染函数,而不是像普通组件那样忽略ref。

在 React 内部,组件会被分为不同的类型,forwardRef创建的组件会被标记为一种特殊的类型。当处理这种类型的组件时,React 会知道要将ref传递给渲染函数,而不是将其作为普通的ref附加到组件实例上(对于类组件)或忽略(对于函数组件)。

三、React.forwardRef的使用场景

  1. 高阶组件(HOC)中转发 ref

    高阶组件是 React 中复用组件逻辑的一种方式,但它可能会导致ref传递出现问题。例如,当我们创建一个 HOC 来包装组件时,外部传递给 HOC 的ref会指向 HOC 本身而不是被包装的原始组件。这时,使用 React.forwardRef 可以将 ref 转发到原始组件上。

    function withLogging(MyComponent) {
      const ComponentWithLogging = React.forwardRef((props, ref) => {
        console.log('Component rendered');
        return <MyComponent ref={ref} {...props} />;
      });
      return ComponentWithLogging;
    }
    
    const MyComponent = (props) => <div {...props} />;
    // 使用withLogging 包裹 MyComponent 形成 MyComponentWithLogging
    const MyComponentWithLogging = withLogging(MyComponent);
    
    // 使用时,ref会指向MyComponent内部的元素
    const ref = React.createRef();
    <MyComponentWithLogging ref={ref} />;
    
  2. 在 UI 组件库中使用

    在开发 UI 组件库时,很多组件是由多个基础元素组合而成的。为了让使用者能够方便地获取到组件内部的某个关键元素(如输入框、按钮等),组件库的开发者可以使用 React.forwardRef 将 ref 转发到对应的内部元素上。

    例如,一个自定义的输入框组件:

    const CustomInput = React.forwardRef((props, ref) => {
      return (
        <div className="custom-input">
          <input ref={ref} {...props} />
        </div>
      );
    });
    

    使用者在使用CustomInput时,可以像使用普通input一样获取ref,从而操作输入框元素。

  3. 与其他 React 特性结合使用

    React.forwardRef 还可以与其他 React 特性(如useImperativeHandle)结合使用,自定义暴露给父组件的实例值。useImperativeHandle可以让组件指定通过ref 暴露给父组件的方法和属性,而React.forwardRef则负责将ref传递进来。

    const CustomComponent = React.forwardRef((props, ref) => {
      const [value, setValue] = React.useState('');
    
      React.useImperativeHandle(ref, () => ({
        focus: () => {
          // 执行聚焦操作
        },
        getValue: () => value
      }));
    
      return <input value={value} onChange={(e) => setValue(e.target.value)} />;
    });
    

    这样,父组件通过ref可以调用focus方法和getValue方法,而不是直接获取input元素。

四、使用React.forwardRef的注意事项

  1. 只在函数组件中使用

    React.forwardRef的渲染函数是一个函数组件,它不能是类组件。如果需要在类组件中使用类似的功能,可以通过其他方式(如将ref作为普通 props 传递,命名为innerRef等)来实现。

  2. 避免过度使用

    虽然React.forwardRef很有用,但也不要过度使用。在大多数情况下,通过 props 传递数据和回调函数已经足够满足需求。只有当确实需要获取组件内部元素的引用时,才考虑使用React.forwardRef。

  3. 类型定义(TypeScript)

    在使用 TypeScript 开发时,需要为forwardRef创建的组件正确定义类型,以确保类型安全。可以使用ForwardedRef和ComponentPropsWithoutRef等类型工具来辅助定义。

    import React, { forwardRef, ForwardedRef } from 'react';
    
    interface CustomInputProps {
      label: string;
    }
    
    const CustomInput = forwardRef((props: CustomInputProps, ref: ForwardedRef<HTMLInputElement>) => {
      return (
        <div>
          <label>{props.label}</label>
          <input ref={ref} />
        </div>
      );
    });
    

五、总结

React.forwardRef是 React 中一个非常实用的 API,它解决了ref在组件层级中传递的问题,使得高阶组件、UI 组件库等场景下的开发更加便捷。通过理解其基本概念、使用场景和工作原理,并注意使用过程中的一些细节,可以更好地发挥它的作用,提高 React 应用的开发效率和代码质量。

类名冲突?样式混乱?CSS Modules 教你优雅应对

样式隔离技术:CSS Modules 详解(小白也能看懂)

在前端开发中,尤其是使用 React、Vue 等现代框架进行组件化开发时,我们经常会遇到一个令人头疼的问题:CSS 类名冲突。 比如,你在一个组件中写了 .login 类,另一个组件中也用了 .login 类,但它们的样式却不一样。这时候,浏览器会按照 CSS 的优先级规则去渲染,可能会导致样式混乱,甚至覆盖掉你原本的样式。 为了解决这个问题,CSS Modules 技术应运而生。它是一种模块化 CSS 的解决方案,可以自动给每个 CSS 类名加上唯一的标识,从而实现样式隔离,避免类名冲突。


一、为什么需要 CSS Modules?

1.1 类名冲突问题

在传统的 CSS 开发中,我们通常会写这样的样式:

.login {
  color: red;
}

然后在另一个文件中也写:

.login {
  color: blue;
}

这两个 .login 类会相互覆盖,最终显示的颜色取决于 CSS 文件加载的顺序,这在大型项目中非常容易出错。

1.2 传统解决方案:手动加前缀

为了避免冲突,一些开发者会采用手动加前缀的方式:

.login-page-login {
  color: red;
}

但这种方式不仅写起来麻烦,而且维护成本高,一旦组件结构变化,类名也要跟着改。


二、CSS Modules 是什么?

CSS Modules 是一种编译时处理 CSS 的技术,它通过自动给类名加上唯一标识符,来确保每个类名在整个项目中都是唯一的。

例如:

.login {
  color: red;
}

在编译后会被自动转换为:

._1y3jg_login {
  color: red;
}

这样,即使你在其他组件中也写了 .login,它会被编译成另一个不同的类名,比如 ._3k9f2_login不会互相影响


三、CSS Modules 的使用方法(以 Less 为例)

CSS Modules 不仅支持普通的 CSS,也支持像 Less、Sass 这样的 CSS 预处理器。下面我们以 Less 为例,介绍如何使用 CSS Modules。

3.1 文件命名规范

要使用 CSS Modules,CSS 文件的命名必须加上 .module.less 后缀:

index.module.less

这样 Webpack、Vite 等构建工具就知道这是一个需要模块化处理的样式文件。

3.2 引入方式

在组件中,使用如下方式引入样式:

import styles from './index.module.less';

这里的 styles 是一个对象,它包含了你在 index.module.less 中定义的所有类名。

3.3 使用类名

在 JSX 中使用时,通过对象属性的方式调用类名:

<div className={styles.login}>登录区域</div>

这个 styles.login 实际上对应的是编译后的唯一类名,比如 _1y3jg_login


四、CSS Modules 的优势

4.1 自动作用域隔离

CSS Modules 最大的优势就是自动隔离样式作用域,每个组件的样式只属于它自己,不会影响到其他组件。

4.2 不需要命名焦虑

你不再需要为类名绞尽脑汁地想“独一无二”的名字,比如 login-page-containerlogin-form-wrapper,直接写 .login 就行。

4.3 提高可维护性

因为样式是模块化的,修改某个组件的样式不会影响到其他组件,维护起来更加安全、方便。


五、举个实际例子

假设我们有两个组件:

  • Login/index.jsxLogin/index.module.less
  • Register/index.jsxRegister/index.module.less

在两个组件的样式文件中都写了:

.login {
  color: red;
}

但在编译后,它们会被转换为:

/* Login/index.module.less 编译后 */
._1y3jg_login {
  color: red;
}

/* Register/index.module.less 编译后 */
._3k9f2_login {
  color: red;
}

这两个类名互不干扰,即使在同一个页面中同时使用,也不会冲突。


六、CSS Modules 的适用场景

  • 组件化开发:尤其是 React、Vue 等框架,每个组件都有自己的样式。
  • 多人协作项目:避免多人同时修改样式时的类名冲突。
  • 中大型项目:样式多、结构复杂,手动管理类名容易出错。

七、CSS Modules 的局限性

虽然 CSS Modules 很强大,但也有它的局限性:

优点 缺点
自动类名隔离 类名变得不直观,调试时可能不太方便
不用担心命名冲突 不能直接通过类名在浏览器控制台查找元素
组件样式独立 无法直接复用类名(需要额外封装)

不过,这些缺点在现代开发工具(如浏览器开发者工具)的支持下,已经可以很好地解决。

组件介绍

本文是系列文章的一部分:框架实战指南 - 基础知识

在深入研究众多前端框架的工作原理之前,我们需要先了解一些基础知识。如果您已经熟悉 DOM 如何表示树状结构以及浏览器如何获取和使用这些信息,那就太好了!您可以继续阅读了!否则,强烈建议您先阅读我们之前的文章,其中介绍了理解本文一些基础知识所需的概念。

您可能听说过现代前端开发人员用来构建大型应用程序的各种框架和库。这些框架包括 Angular、React 和 Vue。虽然每个库都有各自的优缺点,但它们之间有许多核心概念是共享的。

本书将概述这三个框架之间共享的核心概念,以及如何在代码中实现它们。本书将为学习这些框架(即使没有先修知识)提供良好的参考,也适用于学习其他框架(需要其他框架的先修知识)。

首先,让我们解释一下为什么 Angular、React 或 Vue 等框架与之前的其他库(如 jQuery)不同。

所有这些都归结为一个核心概念:组件化

那么,什么是应用程序?

在深入探讨技术层面之前,让我们先从高层次思考一下应用程序由什么组成。

考虑以下应用。

image.png

我们的应用由许多部分组成。例如,包含导航链接的侧边栏、供用户浏览的文件列表,以及有关用户所选文件的详细信息窗格。

而且,应用程序的每个部分都需要不同的东西。

侧边栏可能不需要复杂的编程逻辑,但我们可能希望在用户悬停时为其添加漂亮的颜色和高亮效果。同样,文件列表可能包含复杂的逻辑来处理用户右键单击、拖放文件的操作。

分解开来,应用程序的每个部分都有三个主要关注点:

  • 逻辑——该应用程序的作用是什么?
  • 样式——应用程序的视觉效果如何?
  • 结构——应用程序如何布局?

虽然上面的模型在视觉上展示了不错的内容,但让我们看看应用程序的结构是什么样的:

image.png

这里,每个部分都无需任何额外的样式即可布局:只需一个页面的线框,每个部分都包含以非常直观的方式布局的块。这就是 HTML 帮助我们构建的。

现在我们了解了结构,接下来让我们添加一些功能。首先,我们将在每个部分添加一小段文字来概述我们的目标。之后,我们会将这些内容用作“验收”标准。这就是我们的逻辑将提供给应用的内容。

image.png 太棒了!现在,让我们返回并添加样式来重新创建我们之前的模型!

image.png 我们可以将这个过程的每个步骤想象成我们正在添加一种新的编程语言。

  • HTML 用于添加应用程序的结构。<nav>例如,侧边导航可能是一个标签。
  • JavaScript 在结构之上添加了应用程序的逻辑。
  • CSS 使一切看起来很漂亮,并可能增加一些小的 UX 改进。

我通常这样看待这三项技术:

HTML 就像建筑蓝图。它能让你看到最终效果的总体轮廓。它定义了房屋的墙壁、门和流程。

JavaScript 就像房子里的电路、管道和电器。它们让你能够以有意义的方式与建筑互动。

CSS 就像家里的油漆和其他装饰品一样,它们让房子显得温馨宜人。当然,如果没有家里的其他部分,CSS 的装饰就没什么用,但如果没有装饰,那体验就很糟糕了。

应用程序的组成部分

既然我们已经介绍了应用程序的外观,让我们回顾一下。还记得我说过每个应用程序都是由各个部分组成的吗?让我们将应用程序的模型分解成更小的部分,并更深入地研究它们。

image.png 在这里,我们可以更清楚地看到应用程序的每个部分如何拥有自己的结构、样式和逻辑。

例如,文件列表包含每个文件作为其自身项目的结构、关于哪些按钮执行哪些操作的逻辑以及一些使其看起来引人入胜的 CSS。

本节的代码可能看起来像这样:

<section><button id="addButton"><span class="icon">plus</span></button><!-- ... --></section><ul><li><a href="/file/file_one">File one<span>12/03/21</span></a></li><!-- ... --><ul><script>var addButton = document.querySelector("#addButton");addButton.addEventListener("click", () => {// ...});</script></ul></ul>

我们可能有一个心理模型来将每个部分分解成更小的部分。如果我们用伪代码来表示我们对实际代码库的心理模型,它可能看起来像这样:

<files-buttons><add-button /></files-buttons><files-list><file name="File one" /></files-list>

幸运的是,通过使用框架,这种心理模型可以反映在真实的代码中!

让我们看看<file>每个框架中可能是什么样子的:

<!-- File.vue --><template><div><a href="/file/file_one">File one<span>12/03/21</span></a></div></template>

这是一个特殊命名的.vue文件,它定义了一个名为“File”的 Vue 组件,并有一个模板,当使用该组件时应该显示该 HTML。

与其他需要您明确命名组件的框架不同,Vue 使用文件的名称.vue来定义组件的名称。

每个 Vue 组件都使用一个单独的.vue文件来包含其布局、样式和逻辑。因此,这些.vue文件通常被称为“单文件组件”,简称 SFC。

虽然这个 SFC 看起来与标准 HTML 完全一样,没有添加任何特殊内容,但随着我们对 Vue 的了解越来越多,这种情况很快就会改变。

这些被称为“组件”。组件具有多种方面,我们将在本书的整个过程中进行学习。

我们可以看到每个框架都有自己的语法来显示这些组件,但它们通常比你想象的有更多相似之处。

现在我们已经定义了组件,那么有一个问题:如何在 HTML 中使用这些组件?

渲染应用程序

虽然这些组件看起来像简单的 HTML,但它们却能够实现更高级的用法。因此,每个框架实际上都使用 JavaScript 在屏幕上“绘制”这些组件。

这个“绘制”的过程被称为“渲染”。 然而,渲染并非一次性完成。组件在屏幕上使用的过程中可能会多次渲染,尤其是在需要更新屏幕上显示的数据时;我们将在本章后面详细学习。

传统上,当您仅使用 HTML 构建网站时,您会定义index.html如下文件:

<!-- index.html --><html><body><!-- Your HTML here --></body></html>

类似地,所有使用 React、Angular 和 Vue 构建的应用程序都从一个index.html文件开始。

<!-- index.html --><html><body><div id="root"></div></body></html>

然后,在 JavaScript 中,您将组件“渲染”为一个元素,该元素充当框架的“根”注入站点,以围绕该站点构建 UI。

由于 Vue 的所有组件都位于专用的.vueSFC 中,因此我们必须使用两个不同的文件来渲染基本的 Vue 应用。我们从File.vue组件开始:

<!-- File.vue --><template><div><a href="/file/file_one">File one<span>12/03/21</span></a></div></template>

然后,我们可以将其导入到我们的主要 JavaScript 文件中:

// main.js
import { createApp } from "vue";import File from "./File.vue";createApp(File).mount("#root");

一旦组件被渲染,您就可以用它做更多的事情!

例如,就像 DOM 中的节点有关系一样,组件也有关系。

孩子、兄弟姐妹等等,天哪!

虽然我们的File组件目前包含 HTML 元素,但组件也可能包含其他组件!

.vue正如我们之前提到的,一个SFC中只能有一个组件。这里,我们有一个现有的File组件:

<!-- File.vue --><template><div><a href="/file/file_one">File one<span>12/03/21</span></a></div></template>

我们可以将其import放入另一个组件中使用它:

<!-- FileList.vue --><script setup>import File from "./File.vue";</script><template><ul><li><File /></li></ul></template>

我们可以import立即使用我们的组件,因为我们在其中公开的任何变量<script setup>都会自动在<template>我们的 SFC 部分中可用。

注意,我们的script标签有一个setup属性!没有它,我们的代码就无法正常工作!

我们必须导入父组件中要用到的所有组件!否则,Vue 会抛出错误:

无法解析组件:文件

仔细查看我们的File组件,我们会注意到我们在一个组件内渲染了多个元素。有趣的是,这还有一个有趣的副作用:我们也可以在父组件内渲染多个组件。

<!-- FileList.vue --><script setup>import File from "./File.vue";</script><template><ul><li><File /></li><li><File /></li><li><File /></li></ul></template>

这是组件的一个便捷功能。它允许你重用结构的各个方面(以及样式和逻辑,但我有点跑题了),而无需重复工作。它允许一个非常 DRY 的架构,你的代码只需声明一次,就可以在其他地方重用。

它代表“不要重复自己”,并经常被誉为代码质量的黄金标准!

值得记住的是,我们使用“父级”一词来指代FileList组件与组件之间的关系File。这是因为,就像 DOM 树一样,每个框架的组件集合都反映了一棵树。

image.png 这意味着相关File组件彼此是“兄弟”,每个组件都有一个“父级” FileList

我们可以将这种层次关系扩展到“孙子”及更高级别:

<!-- FileDate.vue -->
<template><span>12/03/21</span></template>
<!-- File.vue -->
<script setup>import FileDate from "./FileDate.vue";</script>
<template><div><a href="/file/file_one">File one<FileDate /></a></div></template>
<!-- FileList.vue -->
<script setup>import File from "./File.vue";</script><template><ul><li><File /></li><li><File /></li><li><File /></li></ul></template>

逻辑

然而,HTML 并不是组件唯一可以存储的内容!正如我们之前提到的,应用程序(以及相应应用程序的每个部分)需要三个部分:

  • 结构(HTML)
  • 样式(CSS)
  • 逻辑(JS)

组件可以处理所有这三种情况!

让我们看看如何通过file-date显示当前日期而不是静态日期来在组件中声明逻辑。

我们首先添加一个变量,该变量以人类可读的字符串形式包含当前日期MM/DD/YY

<!-- FileDate.vue -->
<script setup>const dateStr = `${new Date().getMonth() + 1}/${new Date().getDate()}/${new Date().getFullYear()}`;</script><template><span>12/03/21</span></template>

我们暂时还未使用这个新dateStr变量。这是有意为之;我们很快就会用到它。

虽然设置此变量的逻辑有效,但它有点冗长(并且由于重新创建Date对象三次而很慢) - 让我们将其分解为组件中包含的方法。

function formatDate() {const today = new Date();// Month starts at 0, annoyinglyconst monthNum = today.getMonth() + 1;const dateNum = today.getDate();const yearNum = today.getFullYear();return monthNum + "/" + dateNum + "/" + yearNum;}
<!-- FileDate.vue --><script setup>function formatDate() {const today = new Date();// Month starts at 0, annoyinglyconst monthNum = today.getMonth() + 1;const dateNum = today.getDate();const yearNum = today.getFullYear();return monthNum + "/" + dateNum + "/" + yearNum;}const dateStr = formatDate();</script><template><span>12/03/21</span></template>

副作用简介

让我们formatDate通过告诉组件“一旦你在屏幕上呈现,console.log该数据的值”来验证我们的方法是否输出正确的值。

<!-- FileDate.vue --><script setup>import { onMounted } from "vue";function formatDate() {const today = new Date();// Month starts at 0, annoyinglyconst monthNum = today.getMonth() + 1;const dateNum = today.getDate();const yearNum = today.getFullYear();return monthNum + "/" + dateNum + "/" + yearNum;}const dateStr = formatDate();onMounted(() => {console.log(dateStr);});</script><template><span>12/03/21</span></template>

dateStr在这里,我们告诉每个相应的框架在组件第一次渲染时将值记录到控制台。

等一下,“第一次?”

是的!React、Angular 和 Vue 都可以在需要时更新(或“重新渲染”)。

例如,假设你想dateStr向用户显示 ,但当天晚些时候,时间切换了。虽然你必须处理代码来跟踪时间,但相应的框架会注意到你修改了 的值,dateStr并重新渲染组件以显示新的值

虽然每个框架用来判断何时重新渲染的方法不同,但它们都有一个高度稳定的方法。

考虑支持

这个特性可以说是使用这些框架之一构建应用程序的最显著优势。

这种跟踪数据变化的能力依赖于处理“副作用”的概念。我们将在后续章节“副作用”中更详细地讨论这个问题,你可以将“副作用”理解为对组件数据所做的任何更改:无论是通过用户的输入还是组件的输出变化。

说到在屏幕上更新数据 - 让我们看看如何在页面上动态显示数据。

展示

虽然在控制台中显示值对于调试很有帮助,但对用户来说帮助不大。毕竟,你的用户很可能根本不知道控制台是什么。让我们dateStr在屏幕上显示

<!-- FileDate.vue --><script setup>import { onMounted } from "vue";function formatDate() {const today = new Date();// Month starts at 0, annoyinglyconst monthNum = today.getMonth() + 1;const dateNum = today.getDate();const yearNum = today.getFullYear();return monthNum + "/" + dateNum + "/" + yearNum;}const dateStr = formatDate();</script><template><span>{{ dateStr }}</span></template>

在这里,我们利用了这样的事实:其中的每个变量<script setup>都会自动暴露给我们的<template>代码。

这里,我们使用了每个框架将状态注入组件的方法。对于 React,我们将使用{}语法将 JavaScript 插入模板,而 Vue 和 Angular 都依赖于{{}}语法。

实时更新

但是如果我们dateStr事后更新会发生什么?假设我们有一个setTimeout调用,在 5 分钟后将日期更新为明天的日期。

让我们想想该代码可能是什么样子:

// This is non-framework-specific pseudocode
setTimeout(() => {// 24 hours, 60 minutes, 60 seconds, 1000 millisecondsconst tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000);const tomorrowDate = formatDate(tomorrow);dateStr = tomorrowDate;// This is not a real method in any of these frameworks// But the idea of re-rendering after data has changed IS// an integral part of these frameworks. They just do it differentlyrerender();}, 5000);

让我们看看每个框架在实践中是什么样的:

useState与 React在组件中设置数据的方式类似,Vue 引入了一个名为的 API ref,以便数据更新触发重新渲染。

<!-- FileDate.vue --><script setup>import { ref, onMounted } from "vue";function formatDate(inputDate) {// Month starts at 0, annoyinglyconst monthNum = inputDate.getMonth() + 1;const dateNum = inputDate.getDate();const yearNum = inputDate.getFullYear();return monthNum + "/" + dateNum + "/" + yearNum;}const dateStr = ref(formatDate(new Date()));onMounted(() => {setTimeout(() => {// 24 hours, 60 minutes, 60 seconds, 1000 millisecondsconst tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000);dateStr.value = formatDate(tomorrow);}, 5000);});</script><template><span>{{ dateStr }}</span></template>

注意,我们使用.value来更新 内部的值,<script>但没有.value在 内部使用<template>。这并不是错误——这只是 Vue 的ref工作方式!

如果您在这些屏幕上坐一会儿,您会看到它们自动更新!

这种数据更新触发其他代码的想法被称为“反应性” ,是这些框架的核心部分。

虽然各个框架检测响应式变化的底层机制不同,但它们都会帮你处理 DOM 的更新。这样一来,你就可以专注于更新屏幕内容的逻辑,而不是更新 DOM 本身的代码。

这一点至关重要,因为高效地更新 DOM 需要耗费大量的精力。事实上,其中两个框架(React 和 Vue)将 DOM 的完整副本存储在内存中,以尽可能降低更新的难度。在本系列丛书的第三本《内部原理》中,我们将学习其底层工作原理,以及如何构建我们自己的 DOM 镜像版本。

属性绑定

然而,文本并不是框架能够实时更新的唯一内容!

就像每个框架都有一种方法将状态呈现为屏幕上的文本一样,它也可以更新元素的 HTML 属性。

目前,我们的组件对屏幕阅读器来说date读起来不太友好,因为它只能读出数字。让我们通过在组件中添加一个人类可读的日期来改变这种情况。aria-label``date

<!-- FileDate.vue --><script setup>// ...const dateStr = ref(formatDate(new Date()));</script><template><span aria-label="January 10th, 2023">{{ dateStr }}</span></template>

现在,当我们使用屏幕阅读器时,它会读出“1 月 10 日”而不是“10”。

不过,虽然在动态格式化之前这种方法可能有效date,但在一年中的大部分时间里都不会准确。(幸运的是,坏掉的钟每天至少会有一次是准确的。)

让我们通过添加一个formatReadableDate方法来纠正这个问题,并在属性中反映出来:

<!-- FileDate.vue --><script setup>// ...function formatReadableDate(inputDate) {const months = ["January","February","March","April","May","June","July","August","September","October","November","December",];const monthStr = months[inputDate.getMonth()];const dateSuffixStr = dateSuffix(inputDate.getDate());const yearNum = inputDate.getFullYear();return monthStr + " " + dateSuffixStr + "," + yearNum;}function dateSuffix(dayNumber) {const lastDigit = dayNumber % 10;if (lastDigit == 1 && dayNumber != 11) {return dayNumber + "st";}if (lastDigit == 2 && dayNumber != 12) {return dayNumber + "nd";}if (lastDigit == 3 && dayNumber != 13) {return dayNumber + "rd";}return dayNumber + "th";}const dateStr = ref(formatDate(new Date()));const labelText = ref(formatReadableDate(new Date()));// ...</script><template><span v-bind:aria-label="labelText">{{ dateStr }}</span></template>

在 Vue 中,v-bind有一个更简洁的语法来实现相同的功能。如果你删除v-bind并保留:,效果是一样的。

这意味着:

<span v-bind:aria-label="labelText">{{dateStr}}</span>

和:

<span :aria-label="labelText">{{dateStr}}</span>

两者都可以绑定到 HTML 中的属性。

这段代码可能与您在生产环境中看到的不太一样。如果您打算编写生产代码,您可能需要研究派生值,以便将labelText和 的date值直接基于同一个Date对象。这样可以避免调用new Date两次,但我有点跑题了——我们将在后面的部分讨论派生值。

太棒了!现在,它应该能正确地将文件日期读到屏幕阅读器上了!

输入

我们的文件列表看起来不错!话虽如此,一个重复包含相同文件的文件列表显然不够完整。理想情况下,我们希望将文件名传递给File组件,以增加一些变化。

幸运的是,组件就像函数一样接受参数!在组件世界中,这些参数通常被称为“输入”或“属性”(简称为“props”)。

让我们将文件名作为我们File组件的输入:

<!-- File.vue --><script setup>import FileDate from "./FileDate.vue";const props = defineProps(["fileName"]);</script><template><div><a href="/file/file_one">{{ props.fileName }}<FileDate /></a></div></template>
<!-- FileList.vue --><script setup>import File from "./File.vue";</script><template><ul><li><File :fileName="'File one'" /></li><li><File :fileName="'File two'" /></li><li><File :fileName="'File three'" /></li></ul></template>

我们不需要导入defineProps,相反,Vue 使用一些编译器魔法将其作为全局可访问的方法提供。

在这里,我们需要在组件上声明每个属性defineProps;否则,输入值将无法用于组件的其余部分。

另外,我们在讨论属性绑定时提到:过 是 的简写v-bind:。这里也一样。你也可以这样写:

<File v-bind:fileName="'File three'" />

在这里,我们可以看到每个都File以其自己的名字呈现。

将属性传递给组件的一种思路是将数据“向下传递”给我们的子组件。记住,这些组件彼此之间是父子关系。

我们取得的进展真是令人兴奋!但是哦不——链接仍然是静态的!每个文件都拥有href与上一个文件相同的属性。让我们解决这个问题!

多个属性

与函数类似,组件可以接受任意数量的属性。让我们添加另一个 for href

<!-- File.vue --><script setup>import FileDate from "./FileDate.vue";const props = defineProps(["fileName", "href"]);</script><template><div><a :href="props.href">{{ props.fileName }}<FileDate /></a></div></template>
<!-- FileList.vue --><script setup>import File from "./File.vue";</script><template><ul><li><File :fileName="'File one'" :href="'/file/file_one'" /></li><li><File :fileName="'File two'" :href="'/file/file_two'" /></li><li><File :fileName="'File three'" :href="'/file/file_three'" /></li></ul></template>

对象传递

虽然我们一直使用字符串将值传递给组件作为输入,但情况并非总是如此。

输入属性可以是任何 JavaScript 类型。这可以包括对象、字符串、数字、数组、类实例或介于两者之间的任何类型!

Date为了展示这一点,让我们添加将类实例传递给组件的功能file-date。毕竟,文件列表中的每个文件都可能在不同的时间创建。

<!-- FileDate.vue --><script setup>// ...const props = defineProps(["inputDate"]);const dateStr = ref(formatDate(props.inputDate));const labelText = ref(formatReadableDate(props.inputDate));// ...</script><template><span :aria-label="labelText">{{ dateStr }}</span></template>
<!-- File.vue --><script setup>import FileDate from "./FileDate.vue";const props = defineProps(["fileName", "href"]);const inputDate = new Date();</script><template><div><a :href="props.href">{{ props.fileName }}<FileDate :inputDate="inputDate" /></a></div></template>

再次强调,我必须在此代码示例旁边添加一个小星号。目前,如果您inputDate在初始渲染后更新值,它将不会在 中显示新的日期字符串。这是因为我们只设置了和FileDate的值一次,并且没有更新这些值。dateStr``labelText

正如我们通常所期望的那样,每个框架都有一种通过使用派生值来实时更新此值的方法,但我们将在以后的部分中讨论这一点。

道具规则

虽然组件属性确实可以传递 JavaScript 对象,但是在涉及对象 props 时必须遵循一条规则:

您不能改变组件 prop 值。

例如,以下是一些无法按预期工作的代码:

<!-- GenericList.vue --><script setup>import { onMounted } from "vue";const props = defineProps(["inputArray"]);onMounted(() => {// This is NOT allowed and will break thingsprops.inputArray.push("some value");});</script><!-- ... -->

您不应该改变属性,因为这会破坏带有组件的应用程序架构的两个关键概念:

  1. “纯”函数的含义
  2. 组件流的单向性

事件绑定

将值绑定到 HTML 属性是控制 UI 的有效方法,但这只是故事的一半。向用户显示信息是一回事,但您还必须对用户的输入做出响应。

实现此目的的一种方法是绑定由用户行为发出的 DOM 事件。

在我们之前看到的模型中,文件列表具有悬停状态。但是,当用户点击某个文件时,它应该更加清晰地突出显示。

image.png

让我们isSelected向组件添加一个属性file,以有条件地添加悬停样式,然后在用户单击它时更新它。

既然如此,让我们将File组件迁移到使用button而不是div。毕竟,使用语义元素来指示 DOM 中的哪个元素是哪个元素,对于可访问性和 SEO 来说很重要

<!-- File.vue --><script setup>import { ref } from "vue";const props = defineProps(["fileName"]);const inputDate = new Date();const isSelected = ref(false);function selectFile() {isSelected.value = !isSelected.value;}</script><template><buttonv-on:click="selectFile()":style="isSelected? 'background-color: blue; color: white': 'background-color: white; color: blue'">{{ props.fileName }}<FileDate :inputDate="inputDate" /></button></template>

这里,我们style使用 Vue 的绑定来绑定属性。你可能会注意到,通过 进行绑定时style,我们使用的是对象符号来设置样式,而不是通常的字符串。

我们还使用三元语句condition ? trueVal : falseVal)作为单行if语句来决定使用哪种样式。

我们可以使用v-onbind 前缀将方法绑定到任何事件。这支持任何内置的浏览器事件名称。

还有一个简写语法,就像属性绑定一样。v-on:我们可以使用@符号来代替 。

这意味着:

<button v-on:click="selectFile()"></button>

可以重写为:

<button @click="selectFile()"></button>

输出

组件不仅限于从其父级接收值;您还可以从子组件将值发送回父级。

通常的做法是通过自定义事件向上传递数据,就像浏览器触发的事件一样。就像我们的事件绑定使用了一些新的语法和熟悉的概念一样,我们对事件触发也做了同样的处理。

click虽然在只有一个文件的情况下,组件中监听事件File可以正常工作,但当文件数量较多时,它会导致一些奇怪的行为。也就是说,它允许我们通过点击一次选择多个文件。我们假设这不是预期的行为,而是发出一个selected自定义事件,允许一次只选择一个文件。

Vue 使用全局函数引入了发出事件的想法defineEmits

<!-- File.vue --><script setup>// ...// `href` is temporarily unusedconst props = defineProps(["isSelected", "fileName", "href"]);const emit = defineEmits(["selected"]);</script><template><buttonv-on:click="emit('selected')":style="isSelected? 'background-color: blue; color: white': 'background-color: white; color: blue'">{{ fileName }}<!-- ... --></button></template>

defineEmits函数不需要从 导入vue,因为 Vue 的编译器会为我们处理。

<!-- FileList.vue --><script setup>import { ref } from "vue";import File from "./File.vue";const selectedIndex = ref(-1);function onSelected(idx) {if (selectedIndex.value === idx) {selectedIndex.value = -1;return;}selectedIndex.value = idx;}</script><template><ul><li><File@selected="onSelected(0)":isSelected="selectedIndex === 0"fileName="File one"href="/file/file_one"/></li><li><File@selected="onSelected(1)":isSelected="selectedIndex === 1"fileName="File two"href="/file/file_two"/></li><li><File@selected="onSelected(2)":isSelected="selectedIndex === 2"fileName="File three"href="/file/file_three"/></li></ul></template>

请注意:此代码尚未完全投入生产。此代码存在一些可访问性问题,可能需要aria-selected修复类似 等问题。

这里,我们使用一个简单的基于数字的索引作为id每个文件的排序方式。这使我们能够跟踪当前选中或未选中的文件。同样,如果用户选择了已经被选中的索引,我们会将该isSelected索引设置为一个没有关联文件的数字。

你可能注意到了,我们还isSelected从组件中移除了状态和逻辑file。这是因为我们遵循了“提升状态”的做法

挑战

现在我们已经掌握了组件的基础知识,让我们自己构建一些吧!

也就是说,我希望我们创建以下内容的原始版本:

image.png

为此,让我们:

  1. 创建侧边栏组件
  2. 添加带有侧边栏项目名称的按钮列表
  3. 制作一个ExpandableDropdown组件
  4. name向下拉菜单中添加输入并显示它
  5. expanded向下拉菜单中添加输入并显示它
  6. 使用输出切换expanded输入
  7. 使我们的expanded财产发挥功能

创建我们的第一个组件

让我们通过创建我们的和一个要渲染的基本组件来开始这个过程index.html

<!-- index.html --><html><body><div id="root"></div></body></html>
// main.js
import { createApp } from "vue";import Sidebar from "./Sidebar.vue";createApp(Sidebar).mount("#root");
<!-- Sidebar.vue --><template><p>Hello, world!</p></template>

现在我们已经为组件建立了初始测试平台,让我们添加一个带有侧边栏列表项名称的按钮列表:

<!-- Sidebar.vue --><template><div><h1>My Files</h1><div><button>Movies</button></div><div><button>Pictures</button></div><div><button>Concepts</button></div><div><button>Articles I'll Never Finish</button></div><div><button>Website Redesigns v5</button></div><div><button>Invoices</button></div></div></template>

这种重复的div组合button让我想到我们应该将每个项目提取到一个组件中,因为我们想要:

  • 重复使用 HTML 布局
  • 扩展当前功能

首先将div和提取button到它们自己的组件中,我们将其称为ExpandableDropdown

<!-- Sidebar.vue --><script setup>import ExpandableDropdown from "./ExpandableDropdown.vue";</script><template><div><h1>My Files</h1><ExpandableDropdown name="Movies" /><ExpandableDropdown name="Pictures" /><ExpandableDropdown name="Concepts" /><ExpandableDropdown name="Articles I'll Never Finish" /><ExpandableDropdown name="Website Redesigns v5" /><ExpandableDropdown name="Invoices" /></div></template>
<!-- ExpandableDropdown.vue --><script setup>const props = defineProps(["name"]);</script><template><div><button>{{ props.name }}</button></div></template>

我们现在应该看到一个按钮列表,每个按钮都有一个关联名称!

使我们的组件发挥作用

现在我们已经创建了组件的初始结构,让我们努力使它们发挥作用。

首先,我们将:

  • expanded为每个按钮创建一个属性
  • expanded使用输入传递属性
  • 显示组件expanded内部的值ExpandableDropdown
<!-- Sidebar.vue --><script setup>import { ref } from "vue";import ExpandableDropdown from "./ExpandableDropdown.vue";// Just to show that the value is displaying properlyconst moviesExpanded = ref(true);const picturesExpanded = ref(false);const conceptsExpanded = ref(false);const articlesExpanded = ref(false);const redesignExpanded = ref(false);const invoicesExpanded = ref(false);</script><template><div><h1>My Files</h1><ExpandableDropdown name="Movies" :expanded="moviesExpanded" /><ExpandableDropdown name="Pictures" :expanded="picturesExpanded" /><ExpandableDropdown name="Concepts" :expanded="conceptsExpanded" /><ExpandableDropdownname="Articles I'll Never Finish":expanded="articlesExpanded"/><ExpandableDropdownname="Website Redesigns v5":expanded="redesignExpanded"/><ExpandableDropdown name="Invoices" :expanded="invoicesExpanded" /></div></template>
<!-- ExpandableDropdown.vue --><script setup>const props = defineProps(["name", "expanded"]);</script><template><div><button>{{ props.name }}</button><div>{{ props.expanded ? "Expanded" : "Collapsed" }}</div></div></template>

expanded记得在 里面添加新的属性名称defineProps!否则,此组件将无法正确绑定值。

现在让我们添加一个输出以允许我们的组件切换expanded输入。

<!-- Sidebar.vue --><script setup>import { ref } from "vue";import ExpandableDropdown from "./ExpandableDropdown.vue";// Just to show that the value is displaying properlyconst moviesExpanded = ref(true);const picturesExpanded = ref(false);const conceptsExpanded = ref(false);const articlesExpanded = ref(false);const redesignExpanded = ref(false);const invoicesExpanded = ref(false);</script><template><div><h1>My Files</h1><ExpandableDropdownname="Movies":expanded="moviesExpanded"@toggle="moviesExpanded = !moviesExpanded"/><ExpandableDropdownname="Pictures":expanded="picturesExpanded"@toggle="picturesExpanded = !picturesExpanded"/><ExpandableDropdownname="Concepts":expanded="conceptsExpanded"@toggle="conceptsExpanded = !conceptsExpanded"/><ExpandableDropdownname="Articles I'll Never Finish":expanded="articlesExpanded"@toggle="articlesExpanded = !articlesExpanded"/><ExpandableDropdownname="Website Redesigns v5":expanded="redesignExpanded"@toggle="redesignExpanded = !redesignExpanded"/><ExpandableDropdownname="Invoices":expanded="invoicesExpanded"@toggle="invoicesExpanded = !invoicesExpanded"/></div></template>
<!-- ExpandableDropdown.vue --><script setup>const props = defineProps(["name", "expanded"]);const emit = defineEmits(["toggle"]);</script><template><div><button @click="emit('toggle')">{{ props.name }}</button><div>{{ props.expanded ? "Expanded" : "Collapsed" }}</div></div></template>

最后,我们可以更新组件,使用名为“hidden”的 HTML 属性ExpandableDropdown来隐藏和显示下拉菜单的内容。当此属性为 时,它将隐藏内容;当 时,它将显示内容。true``false

<!-- ExpandableDropdown.vue --><script setup>const props = defineProps(["name", "expanded"]);const emit = defineEmits(["toggle"]);</script><template><div><button @click="emit('toggle')">{{ expanded ? "V" : ">" }}{{ name }}</button><div :hidden="!expanded">More information here</div></div></template>
❌