阅读视图

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

从模板渲染到响应式驱动:前端崛起的技术演进之路

引言:界面是如何“动”起来的?

不论是用户看到的哪个页面,都不应该是一成不变的静态 HTML。

待办事项的增删、商品库存的实时变化,还是聊天消息的即时推送,都让页面指向了一个必需功能————界面必须要随着数据的变化而自动更新

而围绕着这一核心诉求,出现了两条主流路径:

  • 后端动态生成HTML(传统的 MVC 模式): 数据在服务端组装成完整页面,再一次性返还给浏览器。
  • 前端接管界面更新(现代响应式范式): 后端只提供原始数据(如JSON API),而前端通过响应式系统来驱动视图自动同步。

而在这两条路背后,反映着前后端职责划分,同时也催生了以VueReact为代表的前端技术框架革命。

时代一:纯后端渲染 —— MVC 模式主导

假如有一个需求如:写一个简单的 HTTP 服务器,当用户访问 //users 路径时,返回一个包含用户列表的 HTML 页面,其他路径则返回 404 错误。

Node.js早期,如果我想实现这个需求,那么后端渲染将是不二之选。

代码示例:早期 Node.js 实现简单用户列表页

首先就是引入 Node.js 内置模块httpurl,而使用的方法则是Node.js最早的CommonJS 模块系统中的 require()来“导入”

const http = require("http"); // commonjs 
const url = require("url");   // url
  • http 模块:用于创建 HTTP 服务器(处理请求和响应)。
  • url 模块:用于解析浏览器发来的 URL 字符串(如 /users?id=1)。

然后再准备一些模拟数据

const users = [
    { id: 1, name: '张三', email: '123@qq.com' },
    { id: 2, name: '李四', email: '123456@qq.com' },
    { id: 3, name: '王五', email: '121@qq.com' }
]

接下来就要创建生成 HTML 页面的函数了

先使用.map()方法来动态生成表格行,对每个用户生成一行 HTML 表格,用反引号来插入变量,使用 .join('')来拼接所有行,最后返还一个完整的 HTML 文档。

function generateUsersHtml(users) {
    const userRows = users.map(user => `
        <tr>
            <td>${user.id}</td>
            <td>${user.name}</td>
            <td>${user.email}</td>
        </tr>
    `).join('');
    
    return `
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>User List</title>
        <style>
            table { width: 100%; border-collapse: collapse; margin-top: 20px; }
            th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
            th { background-color: #f4f4f4; }
        </style>
    </head>
    <body>
        <h1>Users</h1>
        <table>
            <thead>
                <tr>
                    <th>ID</th>
                    <th>Name</th>
                    <th>Email</th>
                </tr>
            </thead>
            <tbody>
                ${userRows}
            </tbody>
        </table>
    </body>
    </html>
    `;
}

最后也是最重要的就是创建 HTTP 服务器了。

const server = http.createServer((req, res) => {
    const parsedUrl = url.parse(req.url, true);
    
    if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/users') {
        res.statusCode = 200;
        res.setHeader('Content-Type', 'text/html;charset=utf-8');
        const html = generateUsersHtml(users);
        res.end(html);
    } else {
        res.statusCode = 404;
        res.setHeader('Content-Type', "text/plain");
        res.end('Not Found');
    }
});

关键概念解释:

  • req(Request):用户的请求对象,包含 URL、方法、头信息等。
  • res(Response):你要返回给用户的内容,通过它设置状态码、头、正文。

解析 URL

// url.parse(pathname, query)
const parsedUrl = url.parse(req.url, true);
  • req.url即用户请求的 路径+参数部分

  • url.parse()将 URL 字符串“拆解”成结构化的对象,从而方便读取,其中:

    • pathname: 路径部分(如 /users
    • query: 查询参数(如 ?id=1就变成了{ id: '1' }),这里的true是用于判断你是否需要自动解析URL参数部分并转换为对象(通常为 true)

路由判断(简单路由)

if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/users')

如果路径是根路径 / 或 /users,就显示用户列表,否则返回 404。

成功响应(200)

res.statusCode = 200; // 设置状态码为 200
res.setHeader('Content-Type', 'text/html;charset=utf-8');
const html = generateUsersHtml(users);
res.end(html);

通过.setHeader告诉浏览器:“我返回的是 HTML,用 UTF-8 编码”。

然后利用函数 generateUsersHtml(users),传入用户数据,最后调用res.end()生成 HTML 并发送。

错误响应(404 Not Found)

res.statusCode = 404;
res.setHeader('Content-Type', "text/plain");
res.end('Not Found');

状态码 404 表示“页面不存在”,如果产生错误则返还Not Found

注意:url.parse() 是旧 API,现代开发基本弃用


Node.js HTTP服务器启动的最后一步

server.listen(1234, () => {
    console.log('Server is running on port 1234')
})

让服务器监听 1234 端口(任意修改)。此时就可以在浏览器访问:http://localhost:1234http://localhost:1234/users,而访问其他路径(如 /about)会显示 “Not Found”。

效果图:

image.png

核心缺点 + 时代局限性:

  1. 前后端高度耦合,协作效率低下

HTML 结构、样式、JavaScript 逻辑全部硬编码在一个函数里,如果要修改表格样式等操作,就得修改这个函数,并且无法复用。

而这也几乎将前后端工程师捆绑起来了:

  • 前端工程师无法独立开发或调试 UI,必须依赖后端接口和模板
  • 后端工程师被迫处理本应属于前端范畴的展示逻辑

阻碍了团队协作,让前后端工程师的开发体验都极差。

  1. 用户体验受限,交互能力弱

页面完全由服务端生成,每次跳转或操作都需整页刷新,无法实现局部更新、动态加载、表单实时校验等现代 Web 交互,即使只是点击一个按钮,也要重新请求整个 HTML 文档。

时代二:转折点 AJAX 与前后端分离的诞生

在 2005 年之前,Web 应用基本是:用户点击 → 浏览器发请求 → 后端生成完整 HTML → 返回 → 整页刷新 ,导致用户每次交互都像“重新打开一个页面”,体验感大打折扣。

转折事件:Google Maps(2005)首次大规模使用 XMLHttpRequest(XHR)

  • 地图拖拽时不刷新页面
  • 动态加载新区域数据
  • 用户体验飞跃 → 行业震动

这就是 AJAX(Asynchronous JavaScript and XML) 范式的诞生—— “让网页像桌面应用一样流畅”

范式对比再深化

维度 后端渲染(传统) 前后端分离(AJAX 时代)
职责划分 后端一家独大 前端负责 UI/交互,后端负责数据/API
开发模式 全栈一人干 前后端并行开发
部署方式 服务端部署 HTML 前端静态资源(CDN),后端 API(独立服务)
用户体验 卡顿、白屏、跳转 流畅、局部更新、SPA雏形
技术栈 PHP/Java/Node + 模板引擎 HTML/CSS/JS + REST API

代码示例:

已经配置好的环境

在后端 backend 文件夹中包含一个存储用户数据的db.json文件:

{
    "users": [
        {
            "id": 1,
            "name": "张三",
            "email": "123@qq.com"
        },
        {
            "id": 2,
            "name": "李四",
            "email": "1232@qq.com"
        },
        {
            "id": 3,
            "name": "王五",
            "email": "121@qq.com"
        }
    ]
}

注:json-server 会把 JSON 的顶层 key(如 "users")自动映射为 RESTful 路由

package.json 中的脚本

{
  "scripts": {
    "dev": "json-server --watch db.json"
  }
}

就使得运行 npm run dev 时,json-server 会监听 backend/db.json 文件变化(--watch),并且启动一个 HTTP 服务器,默认端口 3000。(别忘了启动后端服务哦~~)

前端代码(重头戏):

基础页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>User List</title>
    <style>
        table { width: 100%; border-collapse: collapse; margin-top: 20px; }
        th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
        th { background-color: #f4f4f4; }
    </style>
</head>
<body>
    <h1>Users</h1>
    <table>
        <thead>
            <tr>
                <th>ID</th>
                <th>Name</th>
                <th>Email</th>
            </tr>
        </thead>
        <tbody>
        </tbody>
    </table>
</body>
</html>

<script>中的内部逻辑

<script>
    // DOM 编程
    fetch('http://localhost:3000/users') // 发出请求
        .then(res => res.json()) // 将 JSON 字符串转为 JS 对象数组
        .then(data => {
            const tbody = document.querySelector('tbody');
            tbody.innerHTML = data.map(user => `
                <tr>
                    <td>${user.id}</td>
                    <td>${user.name}</td>
                    <td>${user.email}</td>
                </tr>
            `).join('');
        })
</script>

.then(data => ...)中的 data 就是上一步 res.json() 解析的结果,并且通过 .map()生成字符串数组,再用.join('') 拼接字符串,而通过 tbody.innerHTML 让浏览器重新解析并渲染表格。

tongyi-mermaid-2025-12-11-165610.png

这代表了“纯手工”前后端分离的起点

  • 前端不再依赖后端吐 HTML
  • 数据通过 JSON API 获取
  • 视图由 JavaScript 动态生成

image.png

但这种方法并非完美,仍然存在痛点:手动操作 DOM 繁琐且易错

举个“胶水代码灾难”的例子:

// 用户点击“删除”
button.onclick = () => {
  fetch(`/api/users/${id}`, { method: 'DELETE' })
    .then(() => {
      // 从列表中移除元素
      li.remove();
      // 更新
      countSpan.textContent = --totalCount;
      // 如果列表空了,显示“暂无数据”
      if (totalCount === 0) emptyMsg.style.display = 'block';
      // 可能还要发埋点、更新缓存、通知其他组件...
    });
};

视图更新逻辑散落在各处,难以维护,极易出错,删除数据要:

  • 找到 <tr> 并删除
  • 更新
  • 找到空状态提示并显示
  • 可能还要:更新侧边栏统计、刷新分页、清除搜索高亮……

每次 UI 变化都要手动找一堆 DOM 节点去修改,并且难以复用。

这时期的前端程序员内心都憋着一句话:我不想再写 document.getElementById 了!

AJAX 让网页活了过来,但也让前端开发者陷入了新的地狱(DOM)——直到框架降临

时代三:革命!响应式数据驱动界面的崛起

核心思想:

“你只管改数据,界面自动更新。”

关键技术:ref 与响应式系统

  • ref() 将普通值包装成响应式对象
  • 模板中通过 {{ }}v-for 声明式绑定数据
  • 数据变化会自动触发视图更新(无需手动 DOM 操作)

响应式(以 Vue 为例)

<template>
  <table>
    <thead>
        <tr>
            <th>ID</th>
            <th>Name</th>
            <th>Email</th>
        </tr>
    </thead>
    <tbody>
      <!-- 遍历数据渲染到界面 -->
      <tr v-for="user in users" :key="user.id">
        <td>{{ user.id }}</td>
        <td>{{ user.name }}</td>
        <td>{{ user.email }}</td>
      </tr>
    </tbody>
  </table>
</template>
  • v-for:声明遍历 users 数组
  • {{ }} :显示 user 的某个属性
  • :key:帮助 Vue 高效追踪列表变化(性能优化)

但是关键在于:我没有写任何 DOM 操作代码!

只是在“描述 UI 应该是什么样子”,而不是“怎么去修改 DOM”。

<script setup>
import { 
  ref,
  onMounted // 挂载之后的生命周期
} from 'vue'; // 响应式api(将数据包装成响应式对象)

// 用 ref() 将普通数组包装成一个 响应式引用对象
const users = ref([]);

// onMounted:确保 DOM 已创建后再发起请求(避免操作不存在的元素)
onMounted(() => {
  console.log('页面挂载完成');
  fetch('http://localhost:3000/users')
    .then(res => res.json())
    .then(data => {
      users.value = data; // 只修改数据
    })
})

// 定时器添加数据
setTimeout(() => {
  users.value.push({
    id: '4',
    name: '钱六',
    email: '12313@qq.com'
  })
}, 3000)
</script>

没有 innerHTML没有 createElement没有 getElementById

并且所有 UI 更新都是数据变化的自然结果,无需人工干预!

2025-12-11.gif

整个历史进程:

阶段 开发模式 核心关注点 开发体验
后端渲染 MVC 数据 → 模板 → HTML 前端边缘化
前后端分离 AJAX + DOM 手动同步数据与视图 繁琐、易错
响应式框架 数据驱动 聚焦业务逻辑 高效、声明式、愉悦

这段短短的 Vue 代码,浓缩了前端开发十年的演进:

  • 从“操作 DOM”到“描述 UI”
  • 从“分散状态”到“单一数据源”
  • 从“易错胶水”到“自动同步”

它让前端开发者终于认识到一个新的自己:前端不再只是“切图仔”,而是复杂应用的架构者与体验设计师。 这,就是 响应式数据驱动界面 的革命性所在。

JS 高手必会:手写 new 与 instanceof

手写 instanceof

首先我们需要了解 instanceof是啥?

在其他面向对象编程语言中instanceof大多为实例判断运算符,即检查对象是否是某个构造函数的实例。

但是在 JS 中,instanceof原型关系判断运算符,用于检测构造函数的prototype属性是否出现在某个对象的原型链上。

// object instanceof Constructor
A instanceof B // A 的原型链上是否有 B 的原型 

而在大型项目、多人协作的情况下,在搞不懂对象上有哪些属性和方法,通过instanceof来查找继承关系

原型链关系:

  • __proto__: 指向原型对象(上一位的 .prototype),用于属性查找
  • constructor: 指向构造函数本身,用于标识对象的创建者
  • prototype: 函数的属性,指向原型对象(内置构造函数的 prototype 上通常有默认方法)
子.__proto__ === 父.prototype
父.prototype.constructor === 父
子.__proto__.constructor === 父(通过原型链访问)

举个最简单的例子:

const arr = [];
console.log(
    arr.__proto__, // Array.prototype
    arr.__proto__.__proto__, // Object.prototype
    arr.__proto__.__proto__.__proto__ // null
)

那么arr的原型链关系就是:arr -> Array.prototype -> Object.prototype -> null

而我们要手写instanceof,也就是只需要沿着原型链去查找,那么用简单的循环即可。

手写代码如下

// right 是否出现在 left 的原型链上
function isInstanceOf(left, right) {
    let proto = left.__proto__;
    // 循环查找原型链
    while (proto) {
        if (proto === right.prototype) {
            return true;
        }
        proto = proto.__proto__; // null 结束循环
    }
    return false;
}

手写 new

new对我们来说并不陌生,也就是实例运算符

在之前的文章我也提到过new的伪代码,让我们再来复习一下:

// 从空对象开始
let obj = {}
// this -> 创建空对象,运行构造函数
Object.call(obj)
// 将空对象的__proto__ 指向 构造函数的原型对象
obj.__proto__ = Object.prototype
// 返回新对象
return obj

写法一:(es6 新写法)

假如我们要new一个实例对象,但是不知道构造函数上的参数数量,而在es6中有一个新的运算符,也就是...运算符,它的诸多功能就可以满足我们的需求。

...扩展运算符

...有双重身份,在不同情况下的作用也不同

  • 函数调用 / 数组 / 对象字面量中...称为扩展运算符,将可迭代对象“展开”为独立元素

  • 函数参数 / 解构赋值中: ...称为剩余参数/剩余元素,将多个值“收集”为一个数组

// 展开数组
const arr1 = [1, 2, 3]; 
const arr2 = [...arr1, 4, 5]; // [1, 2, 3, 4, 5] 

// 收集数组
const [first, ...rest] = [1, 2, 3, 4];
console.log(first); // 1
console.log(rest);  // [2, 3, 4]

而依据我们的伪代码即可模拟 new的功能

手写代码如下

function objectFactory(Construstor, ...args) {
    // 创建空对象
    var obj = new Object(); 
    // 绑定 this 并执行构造函数
    Construstor.apply(obj, args); // 不能使用call,因为apply调用数组
    // 设置原型链
    obj.__proto__ = Construstor.prototype; 
    return obj;
}

// 使用:完全不需要知道 Person 需要几个参数
function Person(name, age, city) {
  this.name = name;
  this.age = age;
  this.city = city;
}

const p = objectFactory(Person, 'Alice', 25, 'Beijing'); // 自动适配

写法二:(根据arguments es5)

当然,在es6之前我们并没有...运算符,那么如何手写new呢?这就不得不提到arguments了。

arguments 是什么?

arguments 是 JS 中的一个类数组对象,它在所有非箭头函数内部自动可用,用于访问传递给该函数的所有实参

类数组对象:

  • 拥有 length 属性和若干索引属性,但不具备数组原型方法(如 .push(), .map(), .forEach() 等)的对象,所以其不是真正的数组

  • 普通函数中自动绑定。

  • 箭头函数内部没有自己的 arguments,但是会沿作用域链查找外层函数的 arguments,如果外层有就用外层的。

不妨来看个例子理解一下:

function greet() {
  console.log(arguments); // 类数组对象
  console.log(arguments.length); // 实际传入参数个数
  console.log(arguments[0]);     // 第一个参数
}
greet('Alice', 'Bob'); // 输出: { '0': 'Alice', '1': 'Bob' }, length: 2, 'Alice'

如何将 arguments 转为真数组?

因为 arguments 不是数组,不能直接用数组方法。但是可以将其转换为数组:

方法 1:扩展运算符(ES6+,最简洁)

function fn() {
  const args = [...arguments];
  args.map(x => x * 2); // 可用数组方法
}

方法 2:Array.from()

const args = Array.from(arguments);

方法 3:[].slice.call()

const args = Array.prototype.slice.call(arguments);
// 或简写(更推荐)
const args = [].slice.call(arguments);

slice是数组原型上的一个方法,用于返回一个从原数组或类数组中浅拷贝出来的新数组(实现将类数组转换成数组)

[].slice是因为arguments上没有这个方法,所以需要去空数组中“借用”,并且通过 .call()slicethis指向arguments,变相的让arguments可以使用这个方法。

在了解了arguments后,聪明的你已经想到了如何通过它来实现手写new

手写代码如下

function objectFactory() {
    // 创建空对象
    var obj = new Object(); 
    // 将 arugments 的第一项提出来(也就是 构造函数)
    var Construstor = [].shift.call(arguments);
    // 绑定 this 并执行构造函数
    Construstor.apply(obj, arguments); // 不能使用call,因为 apply调用数组
    // 设置原型链
    obj.__proto__ = Construstor.prototype; 
    return obj;
}

function Person(name, age, city) {
  this.name = name;
  this.age = age;
  this.city = city;
}

const p = objectFactory(Person, 'Alice', 25, 'Beijing'); // 自动适配

掌握原型链,写出不翻车的 JS 继承

原型与原型链基础

在学习之前需要回顾一下这些基础知识

  • prototype所有函数都包含的一个属性(对象),而对于内置构造函数通常在上面预定义了部分方法,例如:.push.toString等。
  • __proto__所有 JS 对象都有的一个内部属性,指向该对象的原型对象(即父对象的prototype)。
  • constructor 每个 prototype 对象都有一个默认的 constructor 属性,指回其构造函数。

不妨来看个例子:

// 构造函数
function Person(name) {
  this.name = name;
} 

const alice = new Person('Alice');
console.log(alice.__proto__) // Person.prototype
console.log(Person.prototype.constructor) // Person

而它的原型链就是:

// --> 代表.__proto__属性
alice --> Person.prototype --> Object.prototype --> null(所有原型链的终点都是 null)

四种原型继承方式详解

1. 直接赋值父类原型(不推荐)

先来看一个例子:

// 父类构造函数
function Animal(name, age) {
    this.name = name;
    this.age = age;
}
Animal.prototype.species = "动物";

function Cat(color, name, age) {
    // 继承父类的属性
    Animal.apply(this, [name, age]);
    // 使用 .call 也可以
    // Animal.call(this, name, age);
    this.color = color;
}
        
Cat.prototype = Animal.prototype; // 指向父类原型

补充一下 callapply 的区别:

  • call逐个传参,即:fn.call(this, arg1, arg2, ...)
  • apply数组传参,即:fn.apply(this, [arg1, arg2, ...])

这样做下来感觉并没有什么不合适,继承了父类的属性,同时也指向了父类的原型对象。但是这样并不完整,因为如果调用子类的prototype上的constructor属性,正确的继承应该是指向子类自身。

而当我们在代码中执行console.log(Cat.prototype.constructor)最后得到的结果却是 Animal

image.png

所以在最后还需要手动修复构造函数指向,即添加:

Cat.prototype.constructor = Cat;

但是这样做并非万无一失,在这里我们需要了解 JS 的一个特性,那就是 引用式赋值 。在 JS 中,基本数据类型(8种)是按值赋值的,而对象类型是按引用赋值

引用式赋值:指当我将一个对象赋值给另一个变量时,并不是复制了这个对象本身,而是复制了对象在内存中的地址引用,这样就导致两个变量都指向同一个内存位置,不论修改哪个都会对另一个造成影响。

举个最简单的例子:

let obj1 = { name: 'Alice' };
let obj2 = obj1;      // 引用式赋值
obj2.name = 'Bob';

console.log(obj1.name); // "Bob"
console.log(obj1 === obj2); // true(指向同一对象)

回到我们的继承函数,里面就有一个是引用式赋值

Cat.prototype = Animal.prototype;

这就导致了当我们在Cat.prototype上添加方法还是什么的,会污染Animal.prototype,所以尽量别使用直接赋值父类原型

2. 原型链继承(有点缺点)

我们将上面的例子拿下来

function Animal(name, age) {
    this.name = name;
    this.age = age;
}
Animal.prototype.species = "动物";

function Cat(color, name, age) {
    Animal.apply(this, [name, age]);
    this.color = color;
}

但是我们这里使用原型链式继承

Cat.prototype = new Animal(); 
Cat.prototype.constructor = Cat; // 修复构造函数指向     

而这里需要了解一下 new 的伪代码了

// 伪代码 new Animal()
let obj = {};
Animal.call(obj); // 也可以用 apply
obj.__proto__ = Animal.prototype;
return obj

首先创建一个空对象,再将父类的this指向空对象,并将空对象的__proto__指向父类的prototype,也就是连上原型链,最后再返还这个空对象。

但是需要注意的是,这里后续创建的所有实例都是共享父类的属性的,在任意一个实例中对父类属性进行修改都会对其他实例造成影响,例如:

function Animal(name) {
  this.name = name;
  this.colors = ['red', 'blue']; // 引用类型属性
}

function Cat() {}
Cat.prototype = new Animal(); // 所有 Cat 实例共享 colors
Cat.prototype.constructor = Cat;

const cat1 = new Cat();
const cat2 = new Cat();
cat1.colors.push('green');
console.log(cat2.colors); // ['red', 'blue', 'green'] 共享引用

3. 空对象中介模式(经典解决方案)

在直接赋值中,不论怎样都会对父类造成影响,那么如果我们在 父类和子类 中间找一个中介来隔断,是不是就能解决这个问题,而这也是我们最经典的解决方法----空对象中介模式

依旧将前面的例子拿来:

function Animal(name, age) {
    this.name = name;
    this.age = age;
}
Animal.prototype.species = "动物";

function Cat(color, name, age) {
    Animal.apply(this, [name, age]);
    this.color = color;
}

不妨来看看中介模式是怎么使用的

var F = function() {}; // 空对象中介
F.prototype = Animal.prototype;
Cat.prototype = new F();
Cat.prototype.constructor = Cat;

其中我们将F.prototype直接继承Animal.prototype,虽然会导致 引用式赋值,但是只要我对Cat.prototype修改不对F造成影响,那么间接对Animal就没有影响。

而最精妙一点就是Cat.prototype = new F();这步,我们根据之前的伪代码可以知道,这步是将Cat.prototype.__proto__ = F.prototype,也就在变相变成Cat.prototype.__proto__ = Animal.prototype

那么即使我们对Cat.prototype本身进行重新赋值,或者添加任何其他属性也不会影响Cat.prototype.__proto__,除非我们显示修改它(或者对修改F.prototype

拓展:

当然我们也可以将其写成继承函数(extend),这也算手写题吧 QwQ

function extend(Parent, Child) {
    // 中介函数
    var F = function() {}; // 函数表达式(有内存开销,但是因为是空函数问题不大)
    // 指向父类原型
    F.prototype = Parent.prototype;
    // 指向空对象实例
    Child.prototype = new F(); // 实例的修改不会影响原型对象
    // 修复构造函数指向
    Child.prototype.constructor = Child; 
}

extend(Animal, Cat)

4. Object.create()(ES5 推荐方式)

Cat.prototype = Object.create(Animal.prototype);
Cat.prototype.constructor = Cat;

Animal.prototype 为原型创建新对象,在不污染父类构造函数的前提下,更安全地建立子类到父类的原型链连接,并且更加适配现代继承写法。

总结

继承方式 是否推荐 说明
直接赋值原型 污染父类 constructor
原型链继承 引用属性共享问题
中介函数 ✅(兼容旧环境) 安全隔离
Object.create() ✅✅ 现代标准,语义清晰

最佳实践:

  1. 属性继承 → 用 Parent.call(this, ...args)
  2. 方法继承 → 用 Child.prototype = Object.create(Parent.prototype)
  3. 修复 constructor → 显式设置 Child.prototype.constructor = Child

原型继承是 JS 的灵魂。理解 call/apply ,掌握 Object.create 如何安全构建原型链,了解其他构建方法有何不妥,为我们写出健壮的继承结构添一把力

❌