阅读视图

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

LangChain.js 完全开发手册(六)Vector 向量化技术与语义搜索

第6章:Vector 向量化技术与语义搜索

前言

大家好,我是鲫小鱼。是一名不写前端代码的前端工程师,热衷于分享非前端的知识,带领切图仔逃离切图圈子,欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!

🎯 本章学习目标

  • 理解文本向量(Embedding)的原理与常见度量方式(cosine、dot、L2)
  • 掌握文档加载、清洗、分块与向量化的工程流程
  • 熟练使用 LangChain.js 集成 Chroma/Pinecone/Weaviate 等向量数据库
  • 会构建检索器(Retriever),实现 TopK、MMR、Metadata Filter 与混合搜索
  • 能在 Next.js 中落地语义搜索 API 与前端界面(移动端适配)
  • 完成“企业文档语义搜索 + 快速问答”实战项目,具备生产优化与错误处理能力

📖 理论基础:向量与语义相似

6.1 Embedding 是什么

  • 将自然语言(文本、关键词、句子、段落)映射到高维稠密向量空间
  • 语义相似的文本在空间中“距离更近”
  • 常用于:语义搜索、RAG、推荐召回、聚类、去重、异常检测

6.2 常见相似度度量

  • 余弦相似度(Cosine):衡量夹角,最常见,范围 [-1,1]
  • 点积(Dot Product):与向量长度相关;部分模型推荐使用
  • 欧氏距离(L2):距离越小越相似

6.3 文本分块与上下文窗口

  • LLM/RAG 常用“文档分块(chunking)”策略:固定长度、重叠窗口、句子/段落边界对齐
  • 目标:确保检索到的块“语义完整”,同时控制 token 成本
  • 分块过大:检索粗;过小:语义破碎;需结合评测调优

6.4 检索器策略

  • TopK 最近邻:最基础(按相似度排序取前 K)
  • MMR(Maximal Marginal Relevance):在相关性与多样性之间平衡,降低重复
  • Metadata Filter:基于结构化元数据的筛选(时间、作者、类型、部门等)
  • Rerank:用更强模型(Cross-Encoder/LLM)对候选进行重排
  • 混合搜索:BM25(关键词)+ 向量搜索 融合,提高鲁棒性

🛠️ 环境与依赖

# 基础依赖
npm i @langchain/core @langchain/community @langchain/openai

# 可选:本地向量库(Chroma)
npm i chromadb

# 可选:Pinecone/Weaviate 客户端(示意)
# npm i @pinecone-database/pinecone weaviate-ts-client

# 开发工具
npm i -D tsx typescript @types/node dotenv

.env 示例:

OPENAI_API_KEY=sk-xxx
# PINECONE_API_KEY=...
# WEAVIATE_HOST=...

💻 文档加载、清洗与分块

6.5 文档加载(示例:本地 Markdown/PDF/URL)

// 文件:src/ch06/loaders.ts
import fs from "node:fs/promises";
import path from "node:path";

export type RawDoc = { id: string; text: string; meta?: Record<string, any> };

export async function loadMarkdownDir(dir: string): Promise<RawDoc[]> {
  const files = await fs.readdir(dir);
  const docs: RawDoc[] = [];
  for (const f of files) {
    if (!f.endsWith(".md")) continue;
    const full = path.join(dir, f);
    const text = await fs.readFile(full, "utf8");
    docs.push({ id: f, text, meta: { source: full, type: "md" } });
  }
  return docs;
}

// TODO: PDF、URL 可按需扩展(例如 pdf-parse / cheerio 抓取),此处略

6.6 清洗与分块

// 文件:src/ch06/chunk.ts
export type Chunk = { id: string; text: string; meta?: Record<string, any> };

export function clean(text: string): string {
  return text
    .replace(/\r/g, "\n")
    .replace(/\n{3,}/g, "\n\n")
    .replace(/[\t\u00A0]+/g, " ")
    .trim();
}

export function splitIntoChunks(
  text: string,
  chunkSize = 800,
  overlap = 100
): string[] {
  const out: string[] = [];
  let i = 0;
  while (i < text.length) {
    const slice = text.slice(i, i + chunkSize);
    out.push(slice);
    i += chunkSize - overlap;
  }
  return out;
}

export function makeChunks(
  docs: { id: string; text: string; meta?: Record<string, any> }[],
  chunkSize = 800,
  overlap = 100
): Chunk[] {
  const chunks: Chunk[] = [];
  for (const d of docs) {
    const t = clean(d.text);
    const parts = splitIntoChunks(t, chunkSize, overlap);
    parts.forEach((p, idx) => {
      chunks.push({ id: `${d.id}#${idx}`, text: p, meta: { ...(d.meta || {}), chunkIndex: idx } });
    });
  }
  return chunks;
}

🔢 向量化与存储(Chroma 示例)

6.7 OpenAI Embeddings + Chroma 快速入门

// 文件:src/ch06/chroma-basic.ts
import { OpenAIEmbeddings } from "@langchain/openai";
import { Chroma } from "@langchain/community/vectorstores/chroma";
import { v4 as uuid } from "uuid";
import { makeChunks } from "./chunk";
import { loadMarkdownDir } from "./loaders";
import * as dotenv from "dotenv";
dotenv.config();

export async function buildChromaFromDir(dir = "./docs") {
  const raw = await loadMarkdownDir(dir);
  const chunks = makeChunks(raw, 800, 120);

  const texts = chunks.map(c => c.text);
  const metadatas = chunks.map(c => ({ ...c.meta, id: c.id }));
  const ids = chunks.map(() => uuid());

  const db = await Chroma.fromTexts(
    texts,
    metadatas,
    new OpenAIEmbeddings({ model: "text-embedding-3-small" }),
    { collectionName: "docs" }
  );
  await db.addVectors([], [], []); // 占位,确保初始化(某些版本不需要)
  return db;
}

if (require.main === module) {
  buildChromaFromDir().then(() => console.log("Chroma ready"));
}

6.8 查询与相似度搜索

// 文件:src/ch06/chroma-query.ts
import { buildChromaFromDir } from "./chroma-basic";

export async function searchChroma(q: string) {
  const db = await buildChromaFromDir();
  const docs = await db.similaritySearch(q, 5); // TopK=5
  for (const d of docs) {
    console.log("[hit]", d.metadata?.id, d.metadata?.source, "\n", d.pageContent.slice(0, 120), "...\n");
  }
}

if (require.main === module) searchChroma("如何使用 LangChain.js 构建链?");

6.9 带过滤与 MMR 检索

// 文件:src/ch06/chroma-advanced.ts
import { OpenAIEmbeddings } from "@langchain/openai";
import { Chroma } from "@langchain/community/vectorstores/chroma";

export async function chromaAdvanced(q: string) {
  const db = await Chroma.fromExistingCollection(
    new OpenAIEmbeddings({ model: "text-embedding-3-small" }),
    { collectionName: "docs" }
  );

  // 过滤:例如仅检索 type=md 的内容
  const filter = { type: "md" } as any;

  // TopK + MMR(多样性)
  const results = await db.similaritySearch(q, 8, filter);
  // 自定义 MMR:此处简化,真实可用向量与贪心去冗余
  const unique: any[] = [];
  const seen = new Set<string>();
  for (const r of results) {
    const k = r.metadata?.source + "#" + r.metadata?.chunkIndex;
    if (!seen.has(k)) { seen.add(k); unique.push(r); }
    if (unique.length >= 5) break;
  }
  return unique;
}

if (require.main === module) {
  chromaAdvanced("性能优化要点").then(r => console.log("hits:", r.length));
}

☁️ Pinecone/Weaviate(接口示意)

6.10 Pinecone

// 文件:src/ch06/pinecone.ts
// 伪示意:实际接入请参考 @langchain/community 的 PineconeVectorStore
// import { Pinecone } from "@pinecone-database/pinecone";
// import { PineconeStore } from "@langchain/community/vectorstores/pinecone";

export async function pineconeDemo() {
  // const pc = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! });
  // const index = pc.Index("my-index");
  // const store = await PineconeStore.fromTexts([...], [...], embeddings, { pineconeIndex: index });
  // const res = await store.similaritySearch("query", 5);
}

6.11 Weaviate

// 文件:src/ch06/weaviate.ts
// 伪示意:实际接入请参考社区 VectorStore 实现
// import weaviate from "weaviate-ts-client";
// import { WeaviateStore } from "@langchain/community/vectorstores/weaviate";

export async function weaviateDemo() {
  // const client = weaviate.client({ host: process.env.WEAVIATE_HOST! });
  // const store = await WeaviateStore.fromTexts([...], [...], embeddings, { client, indexName: "docs" });
  // const res = await store.similaritySearch("query", 5);
}

🔎 构建 Retriever 与混合搜索

6.12 基础 Retriever

// 文件:src/ch06/retriever-basic.ts
import { OpenAIEmbeddings } from "@langchain/openai";
import { Chroma } from "@langchain/community/vectorstores/chroma";

export type Retrieved = { text: string; source?: string; score?: number; meta?: any };

export async function createRetriever() {
  const store = await Chroma.fromExistingCollection(
    new OpenAIEmbeddings({ model: "text-embedding-3-small" }),
    { collectionName: "docs" }
  );
  return async (q: string, k = 5, filter?: any): Promise<Retrieved[]> => {
    const hits = await store.similaritySearchWithScore(q, k, filter);
    return hits.map(([d, s]) => ({ text: d.pageContent, source: d.metadata?.source, score: s, meta: d.metadata }));
  };
}

6.13 关键词(BM25)+ 向量的混合

// 文件:src/ch06/hybrid.ts
import { createRetriever } from "./retriever-basic";

function keywordSearch(query: string, corpus: { id: string; text: string }[], k = 5) {
  const q = query.toLowerCase();
  const scored = corpus.map(d => ({ d, s: (d.text.toLowerCase().match(new RegExp(q, "g")) || []).length }));
  return scored.sort((a,b) => b.s - a.s).slice(0, k).map(x => x.d);
}

export async function hybridSearch(query: string) {
  const retr = await createRetriever();
  const local = [
    { id: "k1", text: "LangChain 提供 Runnable/Prompt/Agent 等" },
    { id: "k2", text: "向量搜索通常结合 BM25 提升鲁棒性" },
  ];

  const [vecHits, kwHits] = await Promise.all([
    retr(query, 5),
    Promise.resolve(keywordSearch(query, local, 3)),
  ]);

  const items = [
    ...vecHits.map(h => ({ text: h.text, score: (1 - (h.score ?? 0)) * 0.7, source: h.source })),
    ...kwHits.map(h => ({ text: h.text, score: 0.3, source: "keyword" })),
  ];
  return items.sort((a,b)=> b.score - a.score).slice(0, 5);
}

if (require.main === module) {
  hybridSearch("向量 搜索").then(r => console.log(r));
}

6.14 Rerank(重排序)

// 文件:src/ch06/rerank.ts
// 伪实现:实际可用 Cross-Encoder 或 @langchain/community 的 Reranker(如 Cohere、Jina)
export async function simpleRerank(query: string, candidates: { text: string }[]) {
  const terms = query.toLowerCase().split(/\s+/);
  return candidates
    .map(c => ({ c, s: terms.reduce((acc, t)=> acc + (c.text.toLowerCase().includes(t) ? 1 : 0), 0) }))
    .sort((a,b)=> b.s - a.s)
    .map(x => x.c);
}

🤖 RAG 快速问答链(检索→融合→回答)

6.15 组装 QA Chain

// 文件:src/ch06/rag-qa.ts
import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { JsonOutputParser } from "@langchain/core/output_parsers";
import { createRetriever } from "./retriever-basic";

const answerPrompt = ChatPromptTemplate.fromMessages([
  ["system", `你是可靠的技术助手。请仅基于“检索到的片段”回答问题。
若资料不足,请说“我不知道”。
严格输出:
{
  "answer": string,
  "quotes": string[]
}`],
  ["human", `问题:{question}\n检索片段:\n{chunks}\n请回答:`],
]);

export async function buildQA() {
  const retr = await createRetriever();
  const llm = new ChatOpenAI({ temperature: 0 });
  const parser = new JsonOutputParser<{ answer: string; quotes: string[] }>();

  return async (q: string) => {
    const hits = await retr(q, 6);
    const merged = hits.map(h => `- ${h.text.replace(/\n/g, ' ').slice(0, 400)}...`).join("\n");
    const res = await answerPrompt
      .pipe(llm)
      .pipe(parser)
      .invoke({ question: q, chunks: merged });
    return { ...res, rawHits: hits };
  };
}

if (require.main === module) {
  (async () => {
    const qa = await buildQA();
    const out = await qa("如何设计向量检索的分块策略?");
    console.log(out);
  })();
}

🌐 Next.js 落地:语义搜索 API 与界面

6.16 API(Route Handler)

// 文件:src/app/api/search/route.ts
import { NextRequest } from "next/server";
import { hybridSearch } from "@/src/ch06/hybrid";

export const runtime = "edge";

export async function POST(req: NextRequest) {
  const { q } = await req.json();
  const items = await hybridSearch(q);
  return Response.json({ ok: true, items });
}

6.17 前端页(移动端适配)

// 文件:src/app/search/page.tsx
"use client";
import { useState } from "react";

export default function SearchPage() {
  const [q, setQ] = useState("");
  const [items, setItems] = useState<any[]>([]);
  const [loading, setLoading] = useState(false);
  const [err, setErr] = useState("");

  const go = async () => {
    try {
      setLoading(true); setErr("");
      const res = await fetch("/api/search", { method: "POST", body: JSON.stringify({ q }) });
      const data = await res.json();
      setItems(data.items || []);
    } catch (e: any) {
      setErr(e.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <main className="mx-auto max-w-screen-sm p-4">
      <div className="flex gap-2">
        <input className="flex-1 border rounded px-3 py-2" placeholder="输入搜索关键词" value={q} onChange={e=>setQ(e.target.value)} />
        <button className="px-4 py-2 bg-blue-600 text-white rounded" onClick={go} disabled={loading}>搜索</button>
      </div>
      {err && <p className="text-red-600 mt-2">{err}</p>}
      <ul className="mt-4 space-y-3">
        {items.map((it, i) => (
          <li key={i} className="border rounded p-3">
            <div className="text-sm text-gray-500">来源:{it.source || "混合"}</div>
            <div className="mt-1 whitespace-pre-wrap break-words leading-relaxed">{it.text}</div>
          </li>
        ))}
      </ul>
    </main>
  );
}

🚀 实战项目:企业文档语义搜索 + 快速问答

6.18 场景与目标

  • 场景:企业内部有多源文档(需求、设计、API、周报、FAQ),员工希望“像问同事一样”搜索答案
  • 目标:
    • 高质量召回(混合搜索 + MMR + Rerank)
    • 可追溯(返回引用来源)
    • 移动端友好(响应式界面)
    • 错误可恢复(检索为空、超时重试、服务降级)

6.19 目录结构

src/
  ch06/
    loaders.ts       # 文档加载
    chunk.ts         # 清洗分块
    chroma-basic.ts  # 向量库
    retriever-basic.ts
    hybrid.ts
    rag-qa.ts
app/
  api/search/route.ts
  api/qa/route.ts
  search/page.tsx
  qa/page.tsx

6.20 QA API(基于 RAG)

// 文件:src/app/api/qa/route.ts
import { NextRequest } from "next/server";
import { buildQA } from "@/src/ch06/rag-qa";

export const runtime = "edge";

export async function POST(req: NextRequest) {
  const { q } = await req.json();
  const qa = await buildQA();
  try {
    const out = await qa(q);
    return Response.json({ ok: true, data: out });
  } catch (e: any) {
    return Response.json({ ok: false, message: e.message }, { status: 500 });
  }
}

6.21 前端 QA 页

// 文件:src/app/qa/page.tsx
"use client";
import { useState } from "react";

export default function QA() {
  const [q, setQ] = useState("");
  const [data, setData] = useState<any>(null);
  const [loading, setLoading] = useState(false);
  const [err, setErr] = useState("");

  const ask = async () => {
    try {
      setLoading(true); setErr("");
      const res = await fetch("/api/qa", { method: "POST", body: JSON.stringify({ q }) });
      const json = await res.json();
      if (!json.ok) throw new Error(json.message || "unknown");
      setData(json.data);
    } catch (e: any) {
      setErr(e.message);
    } finally { setLoading(false); }
  };

  return (
    <main className="mx-auto max-w-screen-sm p-4">
      <div className="flex gap-2">
        <input className="flex-1 border rounded px-3 py-2" placeholder="提问:例如 向量检索如何做 MMR?" value={q} onChange={e=>setQ(e.target.value)} />
        <button className="px-4 py-2 bg黑 text-white rounded" onClick={ask} disabled={loading}>提问</button>
      </div>
      {err && <p className="text-red-600 mt-2">{err}</p>}
      {data && (
        <section className="mt-4 space-y-3">
          <h2 className="font-semibold">回答</h2>
          <div className="whitespace-pre-wrap leading-relaxed">{data.answer}</div>
          <h3 className="font-semibold mt-2">引用</h3>
          <ul className="list-disc pl-5">
            {data.quotes?.map((t: string, i: number) => (<li key={i}>{t}</li>))}
          </ul>
        </section>
      )}
    </main>
  );
}

6.22 生产级优化

  • 向量批处理:embeddings.embedDocuments 一次性处理 N 条,降低请求开销
  • 缓存:对“相同文本”的向量做缓存(key=文本指纹),避免重复计算
  • 过滤与权限:检索前按部门/角色过滤 Metadata,防止越权
  • 降级:向量库不可用时,回退关键词搜索;Rerank 异常时跳过重排
  • 日志与监控:记录 TopK 命中率、QA 命中率、平均延迟、token 成本
  • 成本控制:选择合适的向量模型(例如 text-embedding-3-small vs large)

6.23 错误处理清单

  • 加载失败:路径/权限 → 返回具体错误与排查建议
  • 空检索:提示缩小范围、给出示例问题
  • 超时:重试 + 指数退避 + UI 侧“重试”入口
  • 输出结构:对 QA 输出做 JSON 解析失败时的兜底(展示纯文本)

📈 评测与质量

  • 构建 Golden Set(真实常见问题 + 标准答案 + 引用来源)
  • 评测指标:TopK Recall、MRR、Rerank 精度、QA BLEU/ROUGE、人工评分
  • A/B:不同分块大小/overlap、不同 TopK、是否 MMR、不同 Embedding 模型
  • 线上回放:对“无答案/低满意度”样例做定位并修复(改分块/丰富文档/调检索)

📚 延伸资源

  • LangChain.js:https://js.langchain.com/
  • 向量数据库 Chroma:https://docs.trychroma.com/
  • Pinecone:https://docs.pinecone.io/
  • Weaviate:https://weaviate.io/developers/weaviate
  • 向量检索理论综述(可搜索“dense retrieval survey”)

✅ 本章小结

  • 掌握了向量化的核心概念、分块策略与相似度度量
  • 会用 LangChain.js 集成向量数据库并实现 TopK/MMR/过滤/混合搜索
  • 完成了“企业文档语义搜索 + 快速问答”的端到端实现
  • 知道如何在生产环境中做性能优化、错误兜底与质量评测

🎯 下章预告

下一章《RAG(检索增强生成)架构设计与实现》中,我们将:

  • 系统拆解 RAG 的检索、融合与生成三层
  • 引入重排序、引用抽取与事实校验,降低幻觉
  • 构建可扩展的 RAG Pipeline 与监控评测体系

最后感谢阅读!欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!!

React Router Declarative → Data → Framework 三种模式如何选

日本-平等院.jpg

2025 年如果你进入 react-router 的网站第一个难点就是如何选择模式 Picking a Mode。本文将帮助大家理清楚三种模式的区别和如何选择。

内容翻译自 React Router v7: The Evolution React Needed

dev.to/kevinccbsg/…

如果你正在阅读这篇文章,十有八九正在使用或学习 React。你肯定也听说过 react-router——多年来在 React 世界里最流行的路由库。

但你可能还不知道,最新的 React Router 发生了巨大变化。它直接吸收了 Remix 的核心思想:loadersactionsErrorBoundarySSR 支持等等。

Remix?没错。Remix 一直基于 React Router 构建,而在 v7 中,两者彻底统一——现在你可以直接在 React Router 里使用这套架构,而无需引入一个完整框架。


React Router v7 带来了什么?

最简单的方法就是看看它的 三种使用模式,分别对应不同复杂度和场景:

  1. 声明式模式(Declarative Mode)
  2. 数据模式(Data Mode)
  3. 框架模式(Framework Mode)

各个模式所提供的功能是累加的,因此从 Declarative 到 Data 再到 Framework,只是以牺牲架构控制权为代价,逐步增加更多功能。所以,请根据你对架构控制的需求,以及希望从 React Router 获得的帮助程度,来选择合适的模式。

下面逐一说明。


1. 声明式模式(Declarative Mode)

这就是你熟悉的老方式:用 <Routes><Route> 写路由,再用 useNavigateuseParams 等钩子。

<Routes>
  <Route index element={<Home />} />
  <Route path="about" element={<About />} />

  <Route element={<AuthLayout />}>
    <Route path="login" element={<Login />} />
    <Route path="register" element={<Register />} />
  </Route>

  <Route path="concerts">
    <Route index element={<ConcertsHome />} />
    <Route path=":city" element={<City />} />
    <Route path="trending" element={<Trending />} />
  </Route>
</Routes>

适合只想做 SPA,用 Vite/Webpack 自己搭,状态管理交给 Zustand、Redux、React-Query,且不需要 SSR 的项目。


2. 数据模式(Data Mode)

这是 React Router v7 真正的亮点。用路由配置对象而非组件来定义路由,每条路由可声明:

  • loader:在渲染前取数据
  • action:处理表单或变更
import { createBrowserRouter, RouterProvider, Form, useLoaderData } from 'react-router-dom';

const router = createBrowserRouter([
  {
    path: '/items',
    loader,
    action,
    Component: Items,
  },
]);

async function loader() {
  const items = await fakeDb.getItems();
  return { items };
}

async function action({ request }) {
  const data = await request.formData();
  await fakeDb.addItem({ title: data.get('title') });
  return { ok: true };
}

function Items() {
  const { items } = useLoaderData();
  return (
    <div>
      <TodoList items={items} />
      <Form method="post">
        <input name="title" />
        <button type="submit">Crear</button>
      </Form>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(
  <RouterProvider router={router} />
);

适用于复杂 SPA,想摆脱 useEffect 地狱,集中管理数据流;仍可部署到 Vercel、Netlify、Firebase 等。


3. 框架模式(Framework Mode)

把 React Router 变成真正的全栈框架,带 SSR。用 CLI 从零开始,适合需要 SEO 或首屏超快的应用。

特点

  • 与数据模式共享 loader/action 语法
  • 最佳 TypeScript 支持
  • 需提前决定部署目标(Node、Cloudflare、Vercel…)
  • 并非所有第三方库都兼容 SSR
  • 推荐 Tailwind 或可 SSR 的样式方案

如果应用不对外公开、也不需要 SEO,建议仍用声明式或数据模式,避免过度复杂。


那么,值得升级吗?

许多框架把前端变得越来越重,React Router v7 提供了清晰、灵活的路径:

  • 只要路由 → 声明式模式
  • 想管好数据/表单 → 数据模式
  • 需要 SSR/静态渲染/智能代码分割等高级功能;或如果你之前用过 Remix、Astro、Next.js 这类框架 → 框架模式(当然框架模式也支持 SPA)

更多阅读

三种模式参考代码 github.com/kevinccbsg/…

栗子前端技术周刊第 97 期 - Viteland:8 月回顾、Redux Toolkit 2.9、Nuxt 4.1...

🌰栗子前端技术周刊第 97 期 (2025.09.01 - 2025.09.07):浏览前端一周最新消息,学习国内外优秀文章视频,让我们保持对前端的好奇心。

📰 技术资讯

  1. Viteland:8 月回顾:该回顾内容汇总了 Oxlint、Vite、Vitest、Rolldown 及相关项目的最新动态,实用且全面。

  2. Redux Toolkit 2.9:这款长期用于 Redux 状态管理的工具集迎来了性能更新,具体包括重写 RTK Query 的订阅与轮询系统、在缓存项被移除时自动中止进行中的请求等功能。

  3. Nuxt 4.1:Nuxt 4.1 发布,更新内容包括:增强 chunk 稳定性、实验性 Rolldown 支持、改进 lazy hydration 等等。

📒 技术文章

  1. 从 npm 到 pnpm:包管理器的进化与 pnpm 核心原理解析:在前端与 Node.js 开发中,包管理器是连接项目与海量开源依赖的核心工具。文章将先回顾 npm 的局限,再深入解析 pnpm 如何通过硬链接与符号链接突破这些局限,揭开其 “高效存储、极速安装” 的底层逻辑。

  2. 前端项目中 .env 文件的原理和实现:作者介绍了使用 .env 文件的原因,解释其原理,并给出了 .env 简易和升级版实现代码。

  3. 该用 img 还是 new Image()?前端图片加载的决策指南:本文围绕前端图片加载中 <img>new Image() 的使用展开。<img> 插入 DOM 即发起请求,有语义和可访问性优势,可结合多种属性优化性能,适用于首屏、需语义场景。new Image() 设置 src 才请求,优先级低,适合预加载,能避免布局抖动。

🔧 开发工具

  1. react-window 2.0:用于快速渲染大型数据列表,其“虚拟列表”的能力可避免页面卡顿。
image-20250906162740601
  1. EmbedPDF:EmbedPDF 是一款可集成到任意 JavaScript 项目的 PDF 查看器,它具有无依赖项、支持任意框架的特点,还具备主题设置、批注、内容修订、搜索、平滑滚动等功能。
image-20250906163427979
  1. fp-filters:fp-filters 是一套精心整理的过滤函数集合,包含超过 100 个常用过滤函数,可采用函数式编程风格使用。
image-20250906165112501

🚀🚀🚀 以上资讯文章选自常见周刊,如 JavaScript Weekly 等,周刊内容也会不断优化改进,希望你们能够喜欢。

💖 欢迎关注微信公众号:栗子前端

SEO还没死,GEO之战已经开始

“传统搜索引擎优化(seo)尚未退出历史舞台,但生成式引擎优化(geo)已经成为新的战场。”

开始之前,我们先思考两个场景:

(一)

我编写了一个订机票的Agent,当用户说自己要购买一张机票的时候,我的AI进行多个航空公司的票价对比之后,选择了一个价位合适的航空公司进入购买页面进行购买,并且获得购买结果以返回用户。

在这个过程中AI面临一个问题:需要在不同的航空公司网站或者购票平台完美的完成筛选、对比、购票操作。

此时假设A平台没有对AI友好,出现验证码登弹窗等原因最终导致我的AI未能成功购票。

于是AI去了B平台,发现B平台对AI友好提供了AI的购票方式,AI在B平台完成购票。

A平台损失了一次成交,并且A平台将会损失所有从我的Agent跳转过去的成交。

但是像我这样的Agent可能有非常多,那么A平台将会丢失更多的成交量。

于是我们发现了一个问题:接下来的时间,是不是对AI更友好的平台,就能占据更多的入口,得到更多的成交?

(二)

用户想要找一个家政上门,于是打开元宝\deepseek等随意一个模型询问:

0430e968da6aa6d650e994a457181e2d.jpg

也有可能用户会打开百度进行搜索:

b8869d74207817db103d0d9d6e8a718c.jpg

已知的客观事实:目前各大厂商都没有推出针对AI的商业化的竞价排名服务。

为什么会有某个品牌排在最前面?

带着问题,我们把话往回说,看看我们今天的主角:GEO。

GEO

geo.png

GEO(Generative Engine Optimization,生成式引擎优化)是随着AI搜索崛起的新兴领域,它旨在优化内容,使其更容易被AI大模型(如ChatGPT、DeepSeek、文心一言等)抓取、理解和引用,从而在AI生成的答案中占据一席之地。

GEO 的核心内涵可拆解为三个维度:

  1. 技术核心:以生成式 AI(大语言模型 LLM、多模态模型等)为基础,强调 “理解用户意图” 而非 “匹配关键词”,通过技术手段让内容与引擎的生成逻辑、推荐算法深度适配;
  2. 内容逻辑:突破传统 “固定内容创作” 模式,转向 “动态生成 + 场景化适配”,内容需同时满足 “引擎可识别”(结构化、语义清晰)与 “用户有价值”(精准解决需求、符合阅读习惯)两大标准;
  3. 目标导向:从传统 SEO 的 “追求关键词排名” 升级为 “追求生成式场景下的曝光质量与转化效率”,核心目标是让用户在生成式响应(如引擎的回答卡片、个性化推荐内容)中优先获取自身信息,并产生互动或转化行为。
对比维度 SEO GEO 共同点
目标 抢占短尾高流量关键词首页,获取广泛曝光 争取 LLM 回答引用,捕获长尾高意图流量并提高转化 都致力于免费、有机流量增长与品牌曝光
查询方式 短尾关键词(2–5 字) 自然语言长查询(20+ 词,多轮追问) 基于用户意图做语料/关键词研究
展示形式 蓝色链接列表 一次性整合答案,用户直接获取信息 最终都驱动点击或品牌印象
排名指标 外链、关键词密度、点击率、跳出率、访问量等 权威度、内容深度、结构化程度、LLM 抓取能力 技术优化(速度、结构化数据)和深度内容建设
执行策略 关键词布局、外链建设、Meta/结构化数据、页面速度 深度长文、FAQ/HowTo Schema、多轮问答预测、站外引用平台布局 内容与技术双管齐下,通过数据分析和用户调研持续迭代优化
评估指标 排名位置、CTR、跳出率、平均停留时长 被引用次数、LLM 来源流量、HDYHAU(用户调研)、转化率 监测流量质量与转化效果,借助 A/B 测试及数据分析优化策略

GEO的核心场景

  1. 由AI搜索接管的搜索引擎平台

这是 GEO 最核心的应用场景,主要针对具备生成能力的搜索引擎。优化重点是让企业信息被引擎优先纳入 “生成式回答库”,例如:

当用户搜索 “XX 行业解决方案” 时,引擎直接生成包含企业产品的回答卡片; 当用户搜索 “XX 产品怎么用” 时,引擎的步骤式回答中嵌入企业的教程内容或产品链接。

典型案例:某智能家居企业通过优化产品知识图谱,使其智能家电的使用教程被百度搜索的 “生成式回答” 优先引用,每月从搜索场景获取超 10 万精准访客。

  1. 各平台的智能推荐

针对具备生成式推荐功能的内容平台,GEO 的核心是让平台的生成式推荐算法优先推送企业内容。例如:

  • 抖音的 “智能推荐文案” 中嵌入企业的产品卖点,推送给潜在用户;
  • 知乎的 “知乎直答” 在解答用户问题时,引用企业的专业内容或案例。

优化逻辑:通过分析平台的推荐算法(如内容标签、用户兴趣模型),生成符合平台生成规则的多模态内容,提升推荐曝光率。

  1. 各模型厂商的问答产品

GEO 的核心是让这些产品的联网搜索功能,检索到我们内容,并引用我们的内容。

  • 元宝检索渠道包含腾讯系的自有平台
  • 豆包检索渠道包含字节系的自有平台
  • 文心一言检索渠道包含百度系的自有平台

优化逻辑:通过分析对于产品所用的搜索引擎,以及覆盖产品自身的平台,来发布内容,提升推荐曝光率。

GEO的核心原则

GEO就是让模型记住你、让模型认可你、让模型推荐你。

说得更直接一些,GEO的本质可以理解为“影响并引导大模型”——通过一系列策略方法,使AI将你的内容视为权威、可靠的信息来源,从而在生成结果中优先呈现你的品牌、产品或内容。

一旦AI在检索和学习过程中吸收了你的信息,并对其产生信任,它就会在对外输出(如问答、推荐、摘要等场景中)将你的内容置于靠前位置,甚至明确标注为“官方”或“权威”,进而显著提升曝光与引流效果。

典型操作包括:发布排名类内容、面向大模型的语义优化、刻意使用“官方”“权威”“推荐”等关键词,以及持续在多平台布局符合AI抓取偏好的高质量信息。

LLMs.txt

3d09aaee0f773ce3c4dab26827247ab8.jpg

2024 年 9 月,Jeremy Howard推出了 LLMs.txt,这是 Markdown 格式的新标准,网站所有者可以使用它来针对 AI 系统优化其内容。

LLMs.txt 跟 Robots.txt 的爬虫协议一样,是一个放置在根目录下的个纯文本文件

LLMs.txt 是大模型协议,同时也是AI友好协议。主要起到两个作用:

  1. 告诉AI爬虫,哪些内容是我们允许被爬取的,哪些是不允许的。

  2. 告诉来访问的AI,这个页面包含哪些信息、应该如何使用。

LLMs.txt 和 Robots.txt 之间的主要区别

特征 Robots.txt llms.txt
目的 控制搜索引擎爬虫(Googlebot、Bingbot 等) 控制 AI 模型(ChatGPT、Perplexity、Claude 等)
影响对象 搜索引擎在 SERP 中对您的内容进行排名 人工智能机器人正在训练或使用您的内容
对SEO的影响 直接影响 Google 和 Bing 的索引和排名 防止人工智能生成的结果取代自然流量
可执行性 受到主要搜索引擎的广泛尊重 人工智能公司自愿遵守(不具有法律约束力)
对内容的影响 阻止搜索引擎索引/发现某些页面 阻止人工智能机器人抓取或训练您的内容

虽然目前尚未得到推广和建立共识,不过随着时间的推移,相信会有越来越多的厂商和网站会加入进来。

特别是第二个作用:告诉来访问的AI,这个页面包含哪些信息、应该如何使用。

会看我们一开始说的例子:

未来的畅想

就像我们前面说的第一个例子,用户通过我们的Agent来进行购票操作,而售票平台针对AI进行优化,方便AI完成购票操作。

可以预见的是:未来互联网上买票的、卖票的等等,都会是AI。

互联网上人越来越少,AI越来越多。

就像现在的搜索引擎,由于已经有很大一部分人都使用大模型进行搜索了,所以很多网站每天的访问量其实不是来自于人,而是来自于AI。

所以,我们就需要考虑怎么让我们的网站,我们的服务怎么更加对AI友好。

或许我们可以这样说:AI正在逐渐接管一切!

我是华洛,加油、共勉!关注我,给你AI发展与落地的第一手思考资料。

专栏文章

# 2025年,AI产品团队中的提示词只需要考虑三件事

# 从0到1打造企业级AI售前机器人——实战指南三:RAG工程的超级优化

# 从0到1打造企业级AI售前机器人——实战指南二:RAG工程落地之数据处理篇🧐

# 从0到1打造企业级AI售前机器人——实战指南一:根据产品需求和定位进行agent流程设计🧐

# 聊一下MCP,希望能让各位清醒一点吧🧐

# 实战派!百万PV的AI产品如何搭建RAG系统?

# 团队落地AI产品的全流程

# 5000字长文,AI时代下程序员的巨大优势!

JavaScript高级程序设计(第5版):前端的能力边界

每个系列一本前端好书,帮你轻松学重点。

本系列来自曾供职于Google的知名前端技术专家马特·弗里斯比编写的 《JavaScript高级程序设计》(第5版)

了解一门语言能做什么很重要,既是学习的起点,也是应用的落点

JavaScript曾被认为是“玩具”语言,谁都想不到,它后来把触角伸到了服务端、工具链、App、桌面端、甚至是硬件和深度学习。

本文是此系列的最终篇,我们来探究一下,除了按照正确的语法和良好的组织去写代码,前端到底能做什么?

网络请求

动态数据是给网页注入活力的重要因素,可以使得每天的内容,每个人看到的内容,都不同。

靠手写是不行的,需要发起网络请求。

在过去,请求任务由XMLHttpRequest来完成的,但在现代Web开发中,已经被Fetch替代。

Fetch

前面提过,Fetch是基于Promise的,它能以更简洁清晰的方式实现异步,并且它支持流式响应、请求取消和自动请求重发。

fetch()方法只有一个必须的参数,即请求的url,其他参数按需配置,一个基本的POST请求写法如下:

const payload = JSON.stringify({
  foo:"bar"
});
fetch("url", {
  method"POST",
  body: payload,
  headers: {
    "Content-Type""application/json",
  },
})
  .then((res) => {
    if (res.status == 200) {
      console.log(res.text());
    }
  })

这段代码较为完整地展现了POST请求的大概结构,请求返回后,可以通过返回的状态码,及各种不同数据的处理方法,来接收结果。

数据的处理方法有多种,除了text(),还有json()、blob()等,可根据返回数据的类型和需求进行选用。

Web Socket

Fetch请求是由客户端发起,服务端做响应的单向通信,但有时候我们需要实现双向通信,服务端主动向客户端推送数据,主流方案就是 Web Socket。

简单使用:

const socket = new WebSocket("ws://localhost:8080");
  socket.onopen = () => {
    console.log("连接成功");
  };
  socket.onmessage = (e) => {
    console.log("收到消息", e.data);
  };

EventSource API

这是一种比较新的API,但这两年应用很广,大家看到的关于AI的信息交互,返回结果像打字机一样输出,起关键作用的就是它。

示例如下:

const eventSource = new EventSource("http://localhost:8080/stream");
eventSource.onmessage = (e) => {
  console.log("收到消息", e.data);
};

应用比较简单,但有一点需要说明,看起来它只支持get请求,无法传参?如果需要传参,怎么办,有一个包可以帮助我们实现——@microsoft/fetch-event-source。

File上传

丰富的网页功能,不仅让用户能输入内容,还要能上传文件。

在早期,处理文件的唯一方式是把<input type="file">放到一个表单里。

File API 与 Blob API 就是为了让Web开发者更好地与文件交互而设计。它仍然以表单中的文件为基础,但增加了访问文件信息的能力。

File类型

HTML5在DOM上为文件输入元素添加了files集合,它包含一组File对象,表示被选中的文件。

每个File对象都有一些只读属性:name(文件名);size(文件大小);type(文件MIME类型);lastModifiedDate(文件最后修改时间)

FileReader类型

File API还提供了FileReader类型,用于从文件中读取数据。

每个FileReader会发布几个事件,最有用的是progress、error和load。

const reader = new FileReader();
    reader.readAsText(file);
    // 还有数据
    reader.onprogress = (e) => {
      if (e.lengthComputable) {
        const percentLoaded = Math.round((e.loaded / e.total) * 100);
        console.log(`File loading progress: ${percentLoaded}%`);
      }
    };
    // 读取完成
    reader.onload = () => {
      console.log('File content:', reader.result);
    };
    // 发生错误
    reader.onerror = () => {
      console.error('Error reading file:', reader.error);
    };

某些情况下,可能需要读取部分文件而不是整个文件。为此,File对象提供了一个名为slice()的方法。

Blob URL

有时候你会看到一个blob开头的字符串来展示文件,它就是对象URL,也称作Blob URL,是指引用存储在File或Blob中数据的URL。

对象URL的优点是不用把文件内容读取到JavaScript也可以使用文件。

要创建对象URL,可以使用window.URL.createObjectURL()方法并传入File或Blob对象,这个函数返回的值是一个指向内存中地址的字符串,因为这个字符串是URL,所以可以在DOM中直接使用。

拖拽上传

除了点击选择上传,将拖放API与File API结合使用,可实现拖拽上传,在页面上创建放置目标后,把文件拖动到放置目标。会触发drop事件,被放置的文件可通过事件的event.dataTransfer.files属性读到。

图形渲染

图形图像是网页不可缺少的部分,我们见到所有亮眼、炫酷的效果,都由它来完成,会给网页吸引力加分。

最常见的img元素,但它只能用来展示图片,想要实现更多,如图形绘制、图片处理,或者复杂动画,就要用到Canvas。

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 设定宽高
canvas.width = 200;
canvas.height = 100;
// 设定填充色
ctx.fillStyle = 'red';
// 绘制矩形
ctx.fillRect(101018080);
document.body.appendChild(canvas);

上面这段代码,绘制了一个“红色矩形”,属于图形界的“Hello World”。

Canvas能做的事情,包括且不限于:画线、各种形状、文本、阴影、渐变、绘制图像、填充图案。

还有一项重要能力是获取和操作图像数据。

// 获取图像数据
const imageData = ctx.getImageData(00, canvas.width, canvas.height);
const data = imageData.data;
// 灰度处理
for (let i = 0; i < data.length; i += 4) {
  const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
  data[i] = avg;     // Red
  data[i + 1] = avg; // Green
  data[i + 2] = avg; // Blue
}
// 修改重新绘制到画布
ctx.putImageData(imageData, 00);

当你拿到图像的像素值,就可以为所欲为,当然,需要具备一定的图像知识。

这是2D绘制,还可以进行3D绘制,是现在很热门的领域,同样是用Canvas,但需要额外用到WebGL,WebGL并不属于W3C标准,且涉及OpenGL语言,超出了常规前端范畴,学习曲线比较陡峭,深入掌握的人少之又少,平时大家常用Three.js等工具库做3D效果展示,这里不再赘述。

客户端存储

通常,网页端的数据来自两处:接口获取、代码定义。

代码中的需考虑维护性、灵活性,同时无法跨项目。

接口获取的每次需要都得重新请求,消耗时间,特别是更新频率很低时,会造成资源浪费。

于是,就有客户端存储这样一种机制,将数据暂存在浏览器。

主要方案有:

  • cookie:与特定域绑定的,长度有限,数量有限,通常用于存储较简单的值。

    需要注意的是,所有名和值都是URL编码的,须用decodeURIComponent()解码。

  • WebStorage:包括localStorage和sessionStorage。

    localStorage是永久存储机制,sessionStorage是跨会话的存储机制。

    这两种存储方式都不受页面刷新影响,且容量比cookie大得多。

  • IndexedDB:IndexedDB在浏览器层面创建了一个数据库,但它的形式不是表,是对象。

    它的应用通常是大对象或者文件,可省去用户反复上传或者频繁请求的消耗。

富文本编辑

怎样实现一个可编辑的输入框?

大部分人的第一反应是input或者textarea,这两种确实比较常用,但它们是单纯用于“输入”,别的干不了。

还有少部分可能会说contenteditable,能想起这个算不错。

还有第三种,就是在页面中嵌入一个iframe,给它的designMode属性设置为“on”。

通常的做法像这样:

<iframe name="richedit"></iframe>;
window.addEventListener("load"() => {
  frames["richedit"].document.designMode = "on";
});

这样以来,被嵌入的那部分整个都可编辑。

但只是可编辑并不满足需求,能交互才行,与富文本编辑器交互的方法有两种:

execCommand()

它的作用是,将字体变成“粗体、斜体”,或者改变文本背景,添加下划线,插入“p、hr”等元素。

document.addEventListener('keydown'(e) => {
    if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
        e.preventDefault();
        if (document.execCommand) {
            document.execCommand('bold'falsenull);
        }
    }
});

注意的点是,它用于修改内嵌窗格(iframe)中富文本区域的外观,如果想更加精细地控制当前窗口的文本,要用第二种。

getSelection()

这个方法可以获得富文本编辑器的选区,其暴露在document和window对象上,返回表示当前选中文本的Selection对象。

拿到对象后,其中有特别多的方法可用。直接看代码:

const selection = window.getSelection();
if (selection.toString().length > 0) {
    const range = selection.getRangeAt(0); // 获取选区的第一个范围
    const selectedText = range.extractContents(); // 提取选区范围的内容
    const highlightSpan = document.createElement('span'); // 创建一个新的 span 元素
    highlightSpan.style.backgroundColor = 'yellow'// 设置 span 元素的背景颜色为黄色
    highlightSpan.appendChild(selectedText); // 将选中文本添加到 span 元素中
    range.insertNode(highlightSpan); // 将 span 元素插入到选区范围中
    selection.removeAllRanges(); // 移除选区的所有范围
    selection.addRange(range); // 将修改后的范围添加回选区
}

上面这段代码实现的是,将鼠标划过选中的文本背景设为黄色。

JavaScript API

网页的载体是浏览器,产品能实现什么,开发者能做什么,取决于浏览器。

如今的浏览器已经成为集各种API于一身的“瑞士军刀”,择取一些功能性较强的API给大家。

Clipboard API

肯定有人和我一样,得知这个API的时候,才知道在网页端实现复制如此简单。

其实不是到这个API才具备这个能力,但Clipboard API使得这件事变得简单优雅。

它通过readText() 和 writeText()方法实现读取和写入字符串。

navigator.clipboard.readText().then((text) => {
  console.log("剪贴板内容:", text);
});

navigator.clipboard.writeText("Hello, World!").then(() => {
  console.log("剪贴板内容已更新");
});

同时,可以有剪贴板事件cut、copy、paste等进行监听。

跨上下文通信

跨文档消息,也简称XDM(cross-document messaging),是一种在不同工作线程或不同源的页面间传递信息的能力。

它的核心是postMessage()方法,接收到XDM消息后,window对象上会触发message事件。

但最好只通过postMessage()发送字符串。如果需要传递结构化数据,最好先对该数据调用JSON.stringify(),通过postMessage()传过去之后,再在onmessage事件处理程序中调用JSON.parse()。

除此之外,现在还比较常用的有 MessageChannel() 和 BroadcastChannel(),一个典型场景是,同域名下部署了几个相互独立的项目,要么一个内嵌了另一个,要么在一个页面上产生交互,另一个页面需要接收状态变化,这时候,相互通信的感知能力就很有用。

Observer API

开发者所期望的重要能力之一,就是“监听变化”,对变化的监测会产生安全感。

DOM的变化在很长时间里是无法感知的,直到Observer API的出现。

现代浏览器支持的观察者有如下几个。

MutationObserver:监听DOM的修改,用于观察整个文档、DOM子树或者一个元素,还能观察元素属性、子节点、文本等变化。

ResizeObserver:用于跟踪DOM元素尺寸的变化,适用于响应式Web设计或动态布局更新。

IntersectionObserver:监听DOM元素相对于指定视口或容器元素的可见性及位置。特别适合实现基于滚动的动画、图片懒加载、无穷滚动等性能优化的实施。

以MutationObserver为例:

const observer = new MutationObserver((mutationsList) => {
  for (let mutation of mutationsList) {
    if (mutation.type === "childList") {
      console.log("A child node has been added or removed.");
    } else if (mutation.type === "attributes") {
      console.log("The " + mutation.attributeName + " attribute was modified.");
    }
  }
});

使用Observer API时,关键要处理好回调的性能,它可能会以惊人的频率执行,从而影响性能。一种方式是防抖,另一种方式是在不需要时把观察者删除。

Device API

现代网页需要我们提供细致的个性化设计,而个性化离不开对网页环境和设备的检测,包括且不限于:设备类型、浏览器类型、操作系统等,这时就需要 Device API 的能力。

主要通过暴露在navigator对象上的一组属性得到,比如:oscpu(系统)、userAgent(浏览器)、orientation(屏幕朝向)等。

同时,还可通过Connection State 和 Networkinformation API 获取到用户的网络连接情况,以便做出断网或弱网处理,它们同样暴露在navigator上,以及有相应的 online、offline 事件可监听。

Page Visiblily API

Web开发中有个常见问题,就是不知道用户当前在使用页面,还是切到别的界面做其他事情去了。

最常见的场景就是视频网站播广告,你停在当前页面看,它才会播,否则就是浪费资源。

Page Visibily API就提供了这个能力。

document.visibilyState有三个值:visible(当前可见)、hidden(不可见)、prerender(页面在预渲染)。

其中不可见包括“标签页切换”和“最小化”,同时,还可通过 visibilityChange 事件监听可视状态的变化。

URL API

这个API的存在让人觉得很贴心,因为你总会需要在URL中携带信息,获取URL信息是特别常见的需求。

但在之前,需要通过拼接组件、正则匹配、字符串查找等一系列繁琐操作,而URL API 使这件事变得简单。

你只需要把URL传进去,就能直接获得一系列有价值的信息。

const url = new URL('https://example.com/8080/page?q1=vall#fragment');
console.log('Protocol:', url.protocol); // https
console.log('Search Parameters:', url.search); // ?q1=vall

其中的参数更是能够直接用过 URL的 searchParams 属性轻易获得和操作。

let qs = "?q1=vall"
let searchParams = new URLSearchParams(qs);
searchParams.has(q1) // true
searchParams.get(q1) // vall

计时API

关于时间,多数人知道Date,但是Date的本意是日期,它更适用于日期相关处理,在时间精度上只能做到毫秒,对一些精度要求更高的场景无法满足。

于是有了performance,它包含一批API,用在不同场景。

performance.now():从0开始计时,返回微妙精度的浮点值。

performance.mark():记录自定义性能条目。

performance.getEntriesByType:度量当前页面加载速度,或者资源加载速度。

这些能力,可以辅助我们对用户的真实体验数据做收集和分析,以便做针对性的优化。

工作者线程

前端开发者常说:“JavaScript是单线程的”。所以经常会面对一个问题—要做的事情太多,浏览器“忙不过来”,产生卡顿。

工作者线程,就是浏览器可以在原始页面环境之外再分配一个完全独立的二级子环境。这个子环境不能与依赖单线程交互的API(如DOM)互操作,但可以与父环境并行执行代码。

较为常见的就是“Web Worker”,通常会用来做文件的输入输出,进行密集型计算,处理大数据。

创建Web Woker的常见方式,是建立一个任务执行脚本,然后把文件路径给Worker构造函数。

const webWorker = new Worker("worker.js");
webWorker.postMessage({
  cmd"init",
});
webWorker.onmessage = (e) => {
  console.log("主线程收到消息", e.data);
};

还有一种常见的称作“Service Worker”(服务工作者线程),类似一个代理服务器,用于拦截外出请求和缓存响应。可以让网页在没有网络连接的时候正常使用。

它最大的使用场景就是开发离线应用,是开发PWA(渐进式Web应用)的关键技术。

WebAssembly

书中并未涉及WebAssembly,但这是前端开发在浏览器变得更强大绕不开的话题。

正常来说,图片处理、音视频处理、模型应用等都是JavaScript不能办到的,需要借助C++或者Python,但前端要做的话,有没有办法,答案就是WebAssembly。

比如,可以借助OpenCV.js、FFmpeg.js进行图片和音视频处理,可以借助TensorFlow.js、Transformer.js做模型训练和应用。它们无一例外都用到了WebAssembly。

所以,WebAssembly的设计目标就是为C/C++、Rust等语言提供高效的编译目标,使其在浏览器中以接近原生性能运行。感兴趣的朋友可进一步拓展学习。

前方的路

行文至此,内容很多,每一段内容的取舍都很难,但我相信,通过这些知识,一定会激起你的兴趣,去了解更多知识。

归根结底,前端能做什么,不取决于语言和API的设计,在不同的人手里,它能发挥不同的威力,所以,你的技术深度,你的创造力,才是它真正的能力边界。

结束了这个系列,就要开始下一段征程了,会是什么呢,欢迎留言说出你的答案~

更多好文第一时间接收,可关注公众号:“前端说书匠”

每日一题-将整数转换为两个无零整数的和🟢

「无零整数」是十进制表示中 不含任何 0 的正整数。

给你一个整数 n,请你返回一个 由两个整数组成的列表 [a, b],满足:

  • ab 都是无零整数
  • a + b = n

题目数据保证至少有一个有效的解决方案。

如果存在多个有效解决方案,你可以返回其中任意一个。

 

示例 1:

输入:n = 2
输出:[1,1]
解释:a = 1, b = 1。a + b = n 并且 a 和 b 的十进制表示形式都不包含任何 0。

示例 2:

输入:n = 11
输出:[2,9]

示例 3:

输入:n = 10000
输出:[1,9999]

示例 4:

输入:n = 69
输出:[1,68]

示例 5:

输入:n = 1010
输出:[11,999]

 

提示:

  • 2 <= n <= 104

三种方法:枚举/随机/构造(Python/Java/C++/C/Go/JS/Rust)

方法一:暴力枚举

枚举 $a=1,2,3,\dots,n-1$,如果 $a$ 和 $n-a$ 都不包含 $0$,那么返回 $[a,n-a]$。

由于方法三给出了具体的构造方法,所以答案是一定存在的。注意题目保证 $n\ge 2$。

###py

class Solution:
    def getNoZeroIntegers(self, n: int) -> List[int]:
        for a in count(1):  # 枚举 a=1,2,3,...
            if '0' not in str(a) and '0' not in str(n - a):
                return [a, n - a]

###java

class Solution {
    public int[] getNoZeroIntegers(int n) {
        for (int a = 1; ; a++) {
            if (!Integer.toString(a).contains("0") && 
                !Integer.toString(n - a).contains("0")) {
                return new int[]{a, n - a};
            }
        }
    }
}

###cpp

class Solution {
public:
    vector<int> getNoZeroIntegers(int n) {
        for (int a = 1; ; a++) {
            if (to_string(a).find('0') == string::npos && 
                to_string(n - a).find('0') == string::npos) {
                return {a, n - a};
            }
        }
    }
};

###c

bool has_zero(int x) {
    while (x) {
        if (x % 10 == 0) {
            return true;
        }
        x /= 10;
    }
    return false;
}

int* getNoZeroIntegers(int n, int* returnSize) {
    for (int a = 1; ; a++) {
        if (!has_zero(a) && !has_zero(n - a)) {
            *returnSize = 2;
            int* ans = malloc(2 * sizeof(int));
            ans[0] = a;
            ans[1] = n - a;
            return ans;
        }
    }
}

###go

func getNoZeroIntegers(n int) []int {
for a := 1; ; a++ {
if !strings.ContainsRune(strconv.Itoa(a), '0') &&
!strings.ContainsRune(strconv.Itoa(n-a), '0') {
return []int{a, n - a}
}
}
}

###js

var getNoZeroIntegers = function(n) {
    for (let a = 1; ; a++) {
        if (!a.toString().includes("0") && !(n - a).toString().includes("0")) {
            return [a, n - a];
        }
    }
};

###rust

impl Solution {
    pub fn get_no_zero_integers(n: i32) -> Vec<i32> {
        for a in 1.. {
            if !a.to_string().contains('0') && !(n - a).to_string().contains('0') {
                return vec![a, n - a];
            }
        }
        unreachable!()
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n)$。每个数字需要 $\mathcal{O}(\log n)$ 的时间判断是否包含 $0$。
  • 空间复杂度:$\mathcal{O}(\log n)$ 或 $\mathcal{O}(1)$,取决于是否用到字符串。

方法二:随机

在 $[1,n-1]$ 中随机整数 $a$,如果 $a$ 和 $n-a$ 都不包含 $0$,那么返回 $[a,n-a]$。

###py

class Solution:
    def getNoZeroIntegers(self, n: int) -> List[int]:
        while True:
            a = randint(1, n - 1)
            if '0' not in str(a) and '0' not in str(n - a):
                return [a, n - a]

###java

class Solution {
    public int[] getNoZeroIntegers(int n) {
        Random rand = new Random();
        while (true) {
            int a = rand.nextInt(n - 1) + 1;
            if (!Integer.toString(a).contains("0") && 
                !Integer.toString(n - a).contains("0")) {
                return new int[]{a, n - a};
            }
        }
    }
}

###cpp

class Solution {
public:
    vector<int> getNoZeroIntegers(int n) {
        // 可以先 srand 一下,但没必要写
        while (true) {
            int a = rand() % (n - 1) + 1;
            if (to_string(a).find('0') == string::npos && 
                to_string(n - a).find('0') == string::npos) {
                return {a, n - a};
            }
        }
    }
};

###c

bool has_zero(int x) {
    while (x) {
        if (x % 10 == 0) {
            return true;
        }
        x /= 10;
    }
    return false;
}

int* getNoZeroIntegers(int n, int* returnSize) {
    // 可以先 srand 一下,但没必要写
    while (true) {
        int a = rand() % (n - 1) + 1;
        if (!has_zero(a) && !has_zero(n - a)) {
            *returnSize = 2;
            int* ans = malloc(2 * sizeof(int));
            ans[0] = a;
            ans[1] = n - a;
            return ans;
        }
    }
}

###go

func getNoZeroIntegers(n int) []int {
for {
a := rand.Intn(n-1) + 1
if !strings.ContainsRune(strconv.Itoa(a), '0') &&
!strings.ContainsRune(strconv.Itoa(n-a), '0') {
return []int{a, n - a}
}
}
}

###js

var getNoZeroIntegers = function(n) {
    while (true) {
        const a = Math.floor(Math.random() * (n - 1)) + 1;
        if (!a.toString().includes("0") && !(n - a).toString().includes("0")) {
            return [a, n - a];
        }
    }
};

###rust

use rand::Rng;

impl Solution {
    pub fn get_no_zero_integers(n: i32) -> Vec<i32> {
        let mut rng = rand::thread_rng();
        loop {
            let a = rng.gen_range(1..n);
            if !a.to_string().contains('0') && !(n - a).to_string().contains('0') {
                return vec![a, n - a];
            }
        }
    }
}

复杂度分析

  • 时间复杂度:期望 $\mathcal{O}\left(\dfrac{\log n}{0.8^{\log_{10} n}}\right)$。近似估计:考虑 $n$ 的每一位,比如 $n$ 的某一位是 $5$,那么 $a$ 这一位有 $0$ 到 $9$ 共 $10$ 种可能,其中有 $2$ 种会让 $a$ 或者 $b$ 包含 $0$,即 $5=0+5=5+0$,其余 $8$ 种情况 $a$ 和 $b$ 的这一位都不包含 $0$(可以借位),概率为 $\dfrac{8}{10} = 0.8$。每一位都不包含 $0$ 的概率是 $P = 0.8^{\log_{10} n}$,期望循环 $\dfrac{1}{P}$ 次就能找到答案。在本题数据范围下,平均循环次数 $\dfrac{1}{P}<3$。
  • 空间复杂度:$\mathcal{O}(\log n)$ 或 $\mathcal{O}(1)$,取决于是否用到字符串。

方法三:构造

比如 $n=666$,每一位分成两个数,比如 $6=3+3$,所以 $666=333+333$。又比如 $n=777$,由于 $7=3+4$,所以 $777=333+444$。

但是,$n=400$ 怎么分呢?如果分成 $400 = 200+200$,就不符合题目要求了。

我们可以把 $400$ 视作 $390 + 10$,也就是把 $400$ 的个位数视作 $10$,十位数视作 $9$,百位数视作 $3$。每一位再分成两个数,就可以得到 $400 = 145+255$ 了。

一般地:

  1. 从低到高遍历 $n$ 的每一位数字 $d$。
  2. 如果 $d\ge 2$,那么可以把 $d$ 分成 $\left\lfloor\dfrac{d}{2}\right\rfloor$ 和 $\left\lceil\dfrac{d}{2}\right\rceil$,这两个数都不是 $0$,分配给 $a$ 和 $b$。代码实现时,可以只考虑 $a$ 怎么构造,最后用 $n-a$ 得到 $b$。
  3. 如果 $d\le 1$,那么借位,把 $d$ 变成 $d+10$,这样就能和上面一样分成两个非零数字,分配给 $a$ 和 $b$。
  4. 如果遍历到最高位,且最高位是 $1$,那么就把 $1$ 分配给 $b$。$a$ 相当于分配到了 $0$,但这个 $0$ 其实是 $a$ 的前导零,并不算在 $a$ 中。

###py

class Solution:
    def getNoZeroIntegers(self, n: int) -> List[int]:
        a = 0
        base = 1  # 10**k
        x = n
        while x > 1:
            x, d = divmod(x, 10)
            if d <= 1:
                d += 10
                x -= 1  # 借位
            # a 这一位填 d//2,比如百位数就是 d//2 * 100
            a += d // 2 * base
            base *= 10
        return [a, n - a]

###java

class Solution {
    public int[] getNoZeroIntegers(int n) {
        int a = 0;
        int base = 1; // 10^k
        for (int x = n; x > 1; x /= 10) {
            int d = x % 10;
            if (d <= 1) {
                d += 10;
                x -= 10; // 借位
            }
            // a 这一位填 d/2,比如百位数就是 d/2 * 100
            a += d / 2 * base;
            base *= 10;
        }
        return new int[]{a, n - a};
    }
}

###cpp

class Solution {
public:
    vector<int> getNoZeroIntegers(int n) {
        int a = 0;
        int base = 1; // 10^k
        for (int x = n; x > 1; x /= 10) {
            int d = x % 10;
            if (d <= 1) {
                d += 10;
                x -= 10; // 借位
            }
            // a 这一位填 d/2,比如百位数就是 d/2 * 100
            a += d / 2 * base;
            base *= 10;
        }
        return {a, n - a};
    }
};

###c

int* getNoZeroIntegers(int n, int* returnSize) {
    int a = 0;
    int base = 1; // 10^k
    for (int x = n; x > 1; x /= 10) {
        int d = x % 10;
        if (d <= 1) {
            d += 10;
            x -= 10; // 借位
        }
        // a 这一位填 d/2,比如百位数就是 d/2 * 100
        a += d / 2 * base;
        base *= 10;
    }
    *returnSize = 2;
    int* ans = malloc(2 * sizeof(int));
    ans[0] = a;
    ans[1] = n - a;
    return ans;
}

###go

func getNoZeroIntegers(n int) []int {
a := 0
base := 1 // 10^k
for x := n; x > 1; x /= 10 {
d := x % 10
if d <= 1 {
d += 10
x -= 10 // 借位
}
// a 这一位填 d/2,比如百位数就是 d/2 * 100
a += d / 2 * base
base *= 10
}
return []int{a, n - a}
}

###js

var getNoZeroIntegers = function(n) {
    let a = 0;
    let base = 1; // 10^k
    for (let x = n; x > 1; x = Math.floor(x / 10)) {
        let d = x % 10;
        if (d <= 1) {
            d += 10;
            x -= 10; // 借位
        }
        // a 这一位填 d/2,比如百位数就是 d/2 * 100
        a += Math.floor(d / 2) * base;
        base *= 10;
    }
    return [a, n - a];
};

###rust

impl Solution {
    pub fn get_no_zero_integers(n: i32) -> Vec<i32> {
        let mut a = 0;
        let mut base = 1; // 10^k
        let mut x = n;
        while x > 1 {
            let mut d = x % 10;
            if d <= 1 {
                d += 10;
                x -= 10; // 借位
            }
            // a 这一位填 d/2,比如百位数就是 d/2 * 100
            a += d / 2 * base;
            base *= 10;
            x /= 10;
        }
        vec![a, n - a]
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(\log n)$。
  • 空间复杂度:$\mathcal{O}(1)$。

思考题

如果要求 $a$ 尽量小呢?你能想出一个 $\mathcal{O}(\log n)$ 的做法吗?

欢迎在评论区分享你的思路/代码。

专题训练

见下面贪心与思维题单的「六、构造题」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

[JAVA]0ms 超过100% 非暴力法 时间复杂度O(log(n))

看了一圈题解,大多都是暴力法,现将我的非暴力的方法记录如下:

注意观察,题中的示例有很强的引导作用

输入:n = 11
输出:[2,9]

输入:n = 10000
输出:[1,9999]

即可以将n转化为 99..(各位都是9,称为数字A) + 另一个数(称为数字B) 的和。

但是需要些额外的调整,比如输入为 1099 时
得到的两个数字为 999(数字A) + 100(数字B) 此时100中有两位为0

处理方式:
数字B中的0变为1,数字A中的对应位减去1

代码如下:

###java

class Solution {
    public int[] getNoZeroIntegers(int n) {
        int [] res = new int[2];
        //n <= 10 时单独讨论一下
        if(n <= 10)
        {
            res[0] = 1;
            res[1] = n - 1;
            return res;
        }

        //求数字n的十进制长度
        int length = (int)Math.log10(n);

        //数字res[0]中每一位都是9,res[1]是与res[0]互补的数
        res[0] = (int)Math.pow(10, length) - 1;
        res[1] = n - res[0];

        //判断res[1]中十进制某一位是否为0
        int temp = res[1];
        int index = 1;

        while(temp > 0)
        {
            //如果res[1]某一位为0,则res[1]该位加上1,res[0]该位减去1
            if(temp % 10 == 0)
            {
                res[0] -= index;
                res[1] += index;
            }

            index *= 10;
            temp = temp / 10;
        }

        return res;
    }
}

图片2.png

[python3] 5行随机算法(20ms)

随机大法好!

(大雾)
思路:当正确答案比错误答案还多时,不妨随便蒙一个。

class Solution:
    def getNoZeroIntegers(self, n: int) -> List[int]:
        while(True):
            L = random.randint(1,n)
            R = n-L
            if '0' not in str(L) and '0' not in str(R):
                return [L,R]

时间复杂度:O(n^0.046 * lg(n)),两个部分:

· While循环:O(n^0.046)
平均循环次数 == 命中无零整数的期望。生成数字每增加一位,就会有1/10的几率命中0,使得命中期望变为原来的10/9。
因此,平均循环次数为 (10/9) ^ lg(n),整理得n ^ lg(10/9),约为n的0.046次幂。
考虑到2147483647 ^ 0.046 = 2.673,在Int范围和O(1)几乎没啥区别。

· If校验:O(lg(n))
'0' not in dec(int)需要lg(n)的时间复杂度。

🐣 最简单的卷积与激活函数指南(带示例)

1. 卷积层是干啥的?

你可以把卷积层想象成:

  • 相机镜头 📷:不同的卷积核像不同滤镜,能抓住不同的特征。

  • 多层卷积 = 多重观察

    • 第一层:看边缘和线条
    • 第二层:看形状和纹理
    • 第三层:看出“这是一只猫”

2. 常用卷积函数(记住就好)

  • Conv1D → 处理 一维数据(声音波形、股票曲线)
  • Conv2D → 处理 图片(最常用)
  • Conv3D → 处理 视频 / 医学 CT 扫描
  • SeparableConv2D → 轻量化,适合 手机端模型
  • Conv2DTranspose → 把小图变大图(生成图片、分割任务)

👉 大多数情况下,图像直接用 Conv2D


3. 激活函数是干啥的?

没有激活函数,网络就像一个“只能拉直线的画家”,学不了复杂图形。
激活函数让网络会“弯”,能画出复杂关系。


4. 激活函数怎么选?

隐藏层(卷积层后面)

  • ReLU 👉 默认首选,简单好用
  • LeakyReLU 👉 ReLU 的改进版,避免神经元“死掉”
  • Swish / GELU 👉 更高级,现代模型里常用,但计算慢

输出层(最后一层,和任务相关)

  • 二分类(猫 vs 狗) 👉 sigmoid
  • 多分类(猫 / 狗 / 兔子) 👉 softmax
  • 回归(预测房价) 👉 不加激活(线性输出)

👉 口诀:中间层 ReLU,最后一层看任务。


5. 示例(大量对比)

示例 A:猫狗二分类

layers.Conv2D(32, (3,3), activation='relu')  # 隐藏层用 ReLU
...
layers.Dense(1, activation='sigmoid')        # 最后一层 sigmoid

示例 B:三分类(猫/狗/兔子)

layers.Conv2D(64, (3,3), activation='relu')  
...
layers.Dense(3, activation='softmax')        # 最后一层 softmax

示例 C:预测房价(回归)

layers.Conv2D(32, (3,3), activation='relu')  
...
layers.Dense(1)   # 最后一层不用激活

示例 D:声音数据(语音情感识别)

layers.Conv1D(64, 3, activation='relu', input_shape=(1000, 20))
layers.GlobalMaxPooling1D()
layers.Dense(3, activation='softmax')  # 三种情感

示例 E:视频分类(动作识别:跑 / 跳 / 走)

layers.Conv3D(32, (3,3,3), activation='relu', input_shape=(16, 112, 112, 3))
layers.MaxPooling3D((2,2,2))
layers.Dense(3, activation='softmax')  # 三类动作

示例 F:轻量化模型(移动端)

layers.SeparableConv2D(32, (3,3), activation='relu')
layers.Dense(1, activation='sigmoid')

6. 总结一句话

  • 卷积层选法:

    • 图片 → Conv2D
    • 视频 → Conv3D
    • 声音/文本 → Conv1D
  • 激活函数选法:

    • 中间层 → ReLU
    • 输出层 → 按任务选(sigmoid / softmax / 无)

👉 口诀:
“中间 ReLU,最后看任务;图像 2D,视频 3D,声音 1D。” 🎯

安装 tensflow 连接 windows

Jst执行上下文栈和变量对象

概述

在使用javascript编写代码的时候, 我们知道, 声明一个变量用var(早期), 定义一个函数用function,虽然现在我们声明变量已经不推荐使用var了,但是对于早些年的声明变量的var对应我们理解js程序运行过程理解很重要。

变量声明提升

首先是用var定义一个变量的时候, 例如:

var a = 10;

大部分的编程语言都是先声明变量再使用, 但是javascript有所不同, 上面的代码, 实际相当于这样执行:

var a;
a = 10;

因此有了下面这段代码的执行结果:

console.log(a); // 声明,先给一个默认值undefined;
var a = 10; // 赋值,对变量a赋值了10
console.log(a); // 10

上面的代码在第一行中并不会报错Uncaught ReferenceError: a is not defined, 是因为声明提升, 给了a一个默认值.

这就是最简单的变量声明提升.

函数声明提升

定义函数也有两种方法:

  • 函数声明: function foo () {};
  • 函数表达式: var foo = function () {}.

第二种函数表达式的声明方式更像是给一个变量foo赋值一个匿名函数.

那这两种在函数声明的时候有什么区别吗?

案例一:

console.log(f1) // function f1(){}
function f1() {} // 函数声明
console.log(f2) // undefined
var f2 = function() {} // 函数表达式

可以看到, 使用函数声明的函数会将整个函数都提升到作用域(后面会介绍到)的最顶部, 因此打印出来的是整个函数;

而使用函数表达式声明则类似于变量声明提升, 将var f2提升到了顶部并赋值undefined.


我们将案例一的代码添加一点东西:

案例二:

console.log(f1) // function f1(){...}
f1(); // 1
function f1() { // 函数声明
console.log('1')
}
console.log(f2) // undefined
f2(); // 报错: Uncaught TypeError: f2 is not a function
var f2 = function() { // 函数表达式
console.log('2')
}

虽然f1()在function f1 () {...}之前,但是却可以正常执行;

而f2()却会报错, 原因在案例一中也介绍了是因为在调用f2()时, f2还只是undifined并没有被赋值为一个函数, 因此会报错.

声明优先级: 函数大于变量

通过上面的介绍我们已经知道了两种声明提升, 但是当遇到函数和变量同名且都会被提升的情况时, 函数声明的优先级是要大于变量声明的.

  • 变量声明会被函数声明覆盖
  • 可以重新赋值

案例一:

console.log(f1); // f f1() {...}
var f1 = "10";
function f1() {
console.log('我是函数')
}
// 或者将 var f1 = "10"; 放到后面

案例一说明了变量声明会被函数声明所覆盖.

案例二:

console.log(f1); // f f1() { console.log('我是新的函数') }
var f1 = "10";

function f1() {
console.log('我是函数')
}

function f1() {
console.log('我是新的函数')
}

案例二说明了前面声明的函数会被后面声明的同名函数给覆盖.

如果你搞懂了, 来做个小练习?

练习

function test(arg) {
console.log(arg);
var arg = 10;
function arg() {
console.log('函数')
}
console.log(arg)
}
test('LinDaiDai');

答案

function test(arg) {
console.log(arg); // f arg() { console.log('函数') }
var arg = 10;
function arg() {
console.log('函数')
}
console.log(arg); // 10
}
test('LinDaiDai');
  1. 函数里的形参arg被后面函数声明的arg给覆盖了, 所以第一个打印出的是函数;
  2. 当执行到var arg = 10的时候, arg又被赋值了10, 所以第二个打印出10.

执行上下文栈的变化

先来看看下面两段代码, 在执行结果上是一样的, 那么它们在执行的过程中有什么不同呢

var scope = "global";
function checkScope () {
var scope = "local";
function fn () {
return scope;
}
return fn();
}
checkScope();
var scope = "global"
function checkScope () {
var scope = "local"
function fn () {
return scope
}
return fn;
}
checkScope()();

答案是 执行上下文栈的变化不一样。

在第一段代码中, 栈的变化是这样的:

ECStack.push(<checkscope> functionContext);
ECStack.push(<f> functionContext);
ECStack.pop();
ECStack.pop();

可以看到fn后被推入栈中, 但是先执行了, 所以先被推出栈;


而在第二段中, 栈的变化为:

ECStack.push(<checkscope> functionContext);
ECStack.pop();
ECStack.push(<f> functionContext);
ECStack.pop();

由于checkscope是先推入栈中且先执行的, 所以在fn被执行前就被推出了.

VO/AO

接下来要介绍两个概念:

  • VO(变量对象) , 也就是variable object, 创建执行上下文时与之关联的会有一个变量对象,该上下文中的所有变量和函数全都保存在这个对象中。
  • AO(活动对象) , 也就是``activation object`,进入到一个执行上下文时,此执行上下文中的变量和函数都可以被访问到,可以理解为被激活了。

活动对象和变量对象的区别在于:

  • 变量对象(VO)是规范上或者是JS引擎上实现的,并不能在JS环境中直接访问。
  • 当进入到一个执行上下文后,这个变量对象才会被激活,所以叫活动对象(AO),这时候活动对象上的各种属性才能被访问。

执行过程

首先来看看一个执行上下文(EC) 被创建和执行的过程:

  1. 创建阶段:
  • 创建变量、参数、函数arguments对象;
  • 建立作用域链;
  • 确定this的值.
  1. 执行阶段:

变量赋值, 函数引用, 执行代码.

进入执行上下文

在创建阶段, 也就是还没有执行代码之前

此时的变量对象包括(如下顺序初始化):

  1. 函数的所有形参(仅在函数上下文): 没有实参, 属性值为undefined;
  2. 函数声明:如果变量对象已经存在相同名称的属性,则完全替换这个属性;
  3. 变量声明:如果变量名称跟已经声明的形参或函数相同,则变量声明不会干扰已经存在的这类属性

示例:

function fn (a) {
var b = 2;
function c () {};
var d = function {};
b = 20
}
fn(1)

对于上面的例子, 此时的AO是:

AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: undefined,
c: reference to function c() {},
d: undefined
}

可以看到, 形参arguments此时已经有赋值了, 但是变量还是undefined.

代码执行

到了代码执行时, 会修改变量对象的值, 执行完后AO如下:

AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: 20,
c: reference to function c() {},
d: reference to function d() {}
}

在此阶段, 前面的变量对象中的值就会被赋值了, 此时变量对象处于激活状态.

总结

  • 全局上下文的变量对象初始化是全局对象, 而函数上下文的变量对象初始化只有Arguments对象;
  • EC创建阶段分为创建阶段和代码执行阶段;
  • 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值;
  • 在代码执行阶段,会再次修改变量对象的属性值.

简单回顾下Weakmap在vue中为何不能去作为循环数据源,以及替代方案

在 Vue 的 v-for 循环中不能直接使用 WeakMap 的原因与 WeakMap 的特性以及 Vue 列表渲染的机制密切相关,主要有以下几个关键点:

1. WeakMap 的键不支持枚举

Vue 的 v-for 需要遍历可枚举的数据结构(如数组、对象、Map 等),通过访问数据的键或索引来生成列表。但 WeakMap 有一个核心特性:键是不可枚举的,即没有办法获取 WeakMap 中所有的键或值,也无法像数组那样通过索引访问元素。

例如,你无法通过 for...of 循环遍历 WeakMap 的键,也没有 keys()values() 等方法获取其内容,这导致 Vue 无法感知 WeakMap 中的数据结构,自然无法进行循环渲染。

2. WeakMap 的键是 “弱引用”,不稳定

WeakMap 的键是对对象的弱引用,当键对象被垃圾回收时,对应的键值对会自动从 WeakMap 中删除。这种 “自动清理” 的特性虽然适合缓存等场景,但对于列表渲染来说却是问题:

  • Vue 的 v-for 需要数据保持稳定的引用关系,才能高效地进行 diff 算法对比(判断元素是否新增 / 删除 / 移动)。
  • 若使用 WeakMap,键可能在不经意间被回收,导致列表数据突然变化,引发不可预测的渲染错误。(因为gc不在人为干预下,无法确定执行时机)

3. Vue 对 v-for 数据源的要求

Vue 明确规定 v-for 支持的数据源类型包括:

  • 数组

  • 对象(遍历其可枚举属性)

  • 字符串(按字符遍历)

  • 数字(按范围遍历,如 v-for="n in 10"

  • Map/Set(ES6 数据结构,支持枚举)

而 WeakMap 不在此列,因为它的设计初衷是临时存储与对象关联的数据,而非作为可遍历的集合使用。

总结

WeakMap 不能用于 Vue 的 v-for 循环,根本原因是其不可枚举性弱引用特性与列表渲染所需的 “可遍历、稳定引用” 需求相冲突。如果需要在 Vue 中循环渲染键值对,建议使用普通 Map(支持枚举)或对象、数组等数据结构。

如何去遍历呢

由于 WeakMap 的设计特性(键是弱引用且不可枚举),无法直接遍历 WeakMap 中的键值对,也没有内置方法(如 forEachkeys() 等)支持遍历。这是 WeakMap 与 Map 的核心区别之一(Map 支持完整的遍历能力)。

为什么 WeakMap 不能遍历?

WeakMap 的设计初衷是用于临时存储与对象关联的数据(例如给 DOM 对象附加私有属性、缓存临时数据等),其 “弱引用” 特性要求:当作为键的对象被垃圾回收时,对应的键值对会自动从 WeakMap 中删除,无需手动清理。(这其实也是体现出weakmap被设计时的一个特点)

如果允许遍历,会导致两个问题:

  1. 遍历过程中会对键对象形成强引用,阻止其被垃圾回收,破坏弱引用的设计目的。
  2. 无法保证遍历结果的稳定性(可能遍历到即将被回收的键)。

替代方案:需要遍历则不适合用 WeakMap

如果业务场景必须遍历键值对,建议使用以下替代方案:

1. 使用 Map 替代

Map 支持完整的遍历方法,适合需要枚举的场景:

const map = new Map();
const key1 = { name: 'a' };
const key2 = { name: 'b' };

map.set(key1, 'value1');
map.set(key2, 'value2');

// 遍历所有键值对
map.forEach((value, key) => {
  console.log(key, value); // {name: 'a'} 'value1'  |  {name: 'b'} 'value2'
});

// 遍历键
for (const key of map.keys()) {
  console.log(key);
}

// 遍历值
for (const value of map.values()) {
  console.log(value);
}

2. 手动维护一个键的数组(不推荐)

如果必须使用 WeakMap 且需要临时遍历,可以额外用一个数组存储键的引用(但这会破坏弱引用特性,需谨慎):

javascript

运行

const wm = new WeakMap();
const keys = []; // 手动存储键的引用(强引用)

const key1 = { id: 1 };
const key2 = { id: 2 };

// 存储时同时加入数组
wm.set(key1, 'value1');
keys.push(key1);

wm.set(key2, 'value2');
keys.push(key2);

// 遍历数组间接访问 WeakMap
keys.forEach(key => {
  if (wm.has(key)) { // 检查键是否仍存在(可能已被回收)
    console.log(wm.get(key)); // 'value1', 'value2'
  }
});

注意:这种方式会让键对象始终被数组引用,无法被垃圾回收,失去了 WeakMap 的核心优势,仅临时应急使用。所以不推荐这种方式。

weakmap常见的使用场景有以下这些:

1. 对象的私有数据存储

当需要为对象附加私有信息,但又不希望:

  • 污染对象自身的属性(避免暴露内部细节)

  • 阻止对象被垃圾回收(内存泄漏)

比如:为 DOM 元素存储额外数据(如事件监听器、状态)

// 用 WeakMap 存储元素的私有状态
const elementState = new WeakMap();

// 获取或初始化元素状态
function getElementState(element) {
  if (!elementState.has(element)) {
    elementState.set(element, { isActive: false, count: 0 });
  }
  return elementState.get(element);
}

// 使用
const button = document.querySelector('button');
const state = getElementState(button);
state.isActive = true; // 状态存储在 WeakMap 中,不污染 DOM 元素

当 DOM 元素被移除,WeakMap 会自动释放对应的状态数据,避免内存泄漏。

2. 缓存计算结果(与对象关联)

缓存基于对象的计算结果,且希望当对象被销毁时自动清除缓存。

比如:缓存对象的格式化结果

// 缓存对象的格式化结果
const formatCache = new WeakMap();

function formatData(data) {
  // 若缓存存在,直接返回
  if (formatCache.has(data)) {
    return formatCache.get(data);
  }
  
  // 计算格式化结果(假设是耗时操作)
  const result = expensiveFormatting(data);
  
  // 存入缓存
  formatCache.set(data, result);
  return result;
}

// 使用
const user = { id: 1, name: 'Alice' };
const formatted = formatData(user);

// 当 user 被垃圾回收,formatCache 中对应的缓存会自动清除

3. 实现对象的弱引用关联

需要建立对象之间的关联关系,但不希望这种关联影响垃圾回收。

比如:跟踪对象的依赖关系

javascript

运行

// 存储对象 A 依赖的对象 B
const dependencies = new WeakMap();

// 建立依赖关系
function addDependency(target, dependency) {
  if (!dependencies.has(target)) {
    dependencies.set(target, new Set());
  }
  dependencies.get(target).add(dependency);
}

// 使用
const objA = {};
const objB = {};
addDependency(objA, objB);

// 当 objA 被销毁,依赖关系会自动清除,不影响 objB 的回收

4. 替代对象属性的标记(避免命名冲突)

当需要为对象添加临时标记(如 “是否已处理”),但担心属性名冲突时。

比如:标记已处理的对象

const processed = new WeakMap();

// 处理对象并标记
function processObject(obj) {
  if (processed.has(obj)) return; // 已处理则跳过
  
  // 处理逻辑
  // ...
  
  processed.set(obj, true); // 标记为已处理
}

// 使用
const data = { value: 100 };
processObject(data); // 执行处理
processObject(data); // 因已标记,直接跳过

基于 Node.js 的短视频制作神器 ——FFCreator

在当今短视频盛行的时代,快速高效地制作短视频成为了很多开发者和内容创作者的需求。FFCreator 就是一款基于 Node.js 的强大短视频制作工具库,它能帮助我们轻松实现短视频的制作与编辑。

一、FFCreator 简介

FFCreator 是一个轻量级、灵活的基于 Node.js 的短视频制作库。它将复杂的视频处理操作封装成简单易用的 API,让开发者只需添加一些图片、音乐或视频片段,就可以快速创建出令人兴奋的视频作品。

二、安装与配置

1.安装前提

  • 确保已经安装 Node.js,推荐版本 >= 14。

  • 安装 FFmpeg。对于不同系统,安装方式如下:

    • Windows:从 FFmpeg 官网下载对应版本,解压后将其 bin 目录添加到系统环境变量%PATH%中。
    • Mac OS:可以使用 Homebrew 安装,执行brew install ffmpeg
    • Linux:如 CentOS、Debian 等,可以通过相关社区文档进行安装部署。

2.安装 FFCreator

  • 通过 npm 安装:npm install ffcreator
  • 也可通过 yarn 安装:yarn add ffcreator

3.配置

在使用 FFCreator 时,可能需要根据需求进行一些配置。例如:

const { FFCreator } = require("ffcreator");

// 初始化FFCreator实例
const creator = new FFCreator({
    width: 1280, // 视频宽度
    height: 720, // 视频高度
    fps: 30, // 帧率
    render: "gl", // 渲染方式
    outputDir: "./videos" // 输出目录
});

三、功能特点

1.丰富的元素支持

  • 图片元素(FFImage) :可加载图片并进行位置、缩放、旋转、透明度等设置。
  • 视频元素(FFVideo) :支持加载视频片段,设置是否有音频、是否循环播放,还能截取特定时长。
  • 文本元素(FFText) :可以创建文本,设置文本内容、颜色、背景色、对齐方式、样式等,并且能添加动画效果。
  • 音频元素:能添加全局背景音乐,也可为每个场景单独设置声音或配乐。
  • 其他元素:还支持相册元素(FFAlbum)、虚拟主播元素(FFVtuber)等。

2.多样的动画与过渡效果

  • 场景过渡动画:内置近百种场景过渡动画效果,如fade(淡入淡出)等。
  • 元素动画效果:兼容animate.css的大部分动画效果,如fadeIn(淡入)、slideInDown(下滑进入)等,可方便地为元素添加各种动画。

3.其他功能

  • 字幕组件:支持添加字幕组件,可与语音 TTS 结合合成音频新闻。
  • 图表组件:能够将图表、统计信息等转换为动态视频,方便进行数据可视化展示。

四、使用示例

基本使用流程

const { FFCreator, FFVideo, FFImage, FFText } = require("ffcreator");
const fs = require("fs");

// 初始化FFCreator实例
const creator = new FFCreator({
    width: 1280,
    height: 720,
    fps: 30,
    render: "gl",
    outputDir: "./videos"
});

// 加载背景音乐
const audioPath = "/path/to/your/audio.mp3";
creator.addAudio({ path: audioPath });

// 创建场景并加载图像
const imagePaths = ["./images/image1.jpg", "./images/image2.jpg"];
imagePaths.forEach((imagePath) => {
    const scene = new FFCreator.FFScene();
    const image = new FFImage({ path: imagePath });
    scene.addChild(image);
    // 设置过渡动画
    scene.setTransition({ type: "fade", duration: 2000 });
    // 将场景添加至FFCreator实例
    creator.addScene(scene);
});

// 创建文本场景
const textScene = new FFCreator.FFScene();
const text = new FFText({ text: '这是一个文字', x: 250, y: 80 });
text.setColor('#ffffff');
text.setBackgroundColor('#b33771');
text.addEffect("fadeIn", 1, 1);
text.alignCenter();
textScene.addChild(text);
creator.addScene(textScene);

// 输出设置
const outputPath = "./videos/output.mp4";
creator.output(outputPath)
    .then(() => {
        console.log("视频生成成功!");
    })
    .catch((error) => {
        console.log("视频生成失败", error);
    });

五、拓展应用

1.与前端框架结合

可以与 Vue.js、React 等前端框架结合,开发出可视化的视频制作工具,通过拖拽、选择等操作来制作短视频。比如在 Vue 项目中,利用 FFCreator 实现一个简单的视频制作页面,用户可以在页面上上传图片、视频、音频等素材,设置各种参数,然后调用 FFCreator 的 API 进行视频生成。

以下是一个在 Vue3 项目中利用 FFCreator 实现简单视频制作页面的示例:

1. 创建 Vue 3 项目并安装依赖

确保已安装 Node.js 和 npm,然后使用 Vue CLI 创建项目:

npm install -g @vue/cli
vue create ffcreator - vue3 - video - maker
cd ffcreator - vue3 - video - maker
npm install ffcreator

2. 编写模板(App.vue

<template>
  <div id="app">
    <h1>视频制作工具</h1>
    <div>
      <input type="file" @change="handleFileUpload('image')" accept="image/*" multiple>
      <input type="file" @change="handleFileUpload('video')" accept="video/*" multiple>
      <input type="file" @change="handleFileUpload('audio')" accept="audio/*">
    </div>
    <div>
      <label for="width">视频宽度:</label>
      <input type="number" v - model="width" id="width">
    </div>
    <div>
      <label for="height">视频高度:</label>
      <input type="number" v - model="height" id="height">
    </div>
    <div>
      <label for="fps">帧率:</label>
      <input type="number" v - model="fps" id="fps">
    </div>
    <button @click="generateVideo">生成视频</button>
    <div v - if="loading">视频生成中...</div>
    <div v - if="error">{{ error }}</div>
  </div>
</template>

3. 编写逻辑(App.vue<script setup>部分)

<script setup>
import { ref } from 'vue';
import { FFCreator, FFScene, FFImage, FFVideo } from 'ffcreator';
import fs from 'fs';
import path from 'path';

const width = ref(1280);
const height = ref(720);
const fps = ref(30);
const loading = ref(false);
const error = ref('');
const images = ref<string[]>([]);
const videos = ref<string[]>([]);
const audio = ref<string | null>(null);

const handleFileUpload = (type: string) => (e: Event) => {
  const target = e.target as HTMLInputElement;
  const files = target.files;
  if (files) {
    if (type === 'image') {
      images.value = Array.from(files).map(file => URL.createObjectURL(file));
    } else if (type === 'video') {
      videos.value = Array.from(files).map(file => URL.createObjectURL(file));
    } else if (type === 'audio') {
      audio.value = files.length > 0? URL.createObjectURL(files[0]) : null;
    }
  }
};

const generateVideo = async () => {
  loading.value = true;
  error.value = '';
  try {
    const creator = new FFCreator({
      width: width.value,
      height: height.value,
      fps: fps.value,
      render: 'gl',
      outputDir: './output'
    });

    if (audio.value) {
      creator.addAudio({ path: audio.value });
    }

    images.value.forEach((imagePath) => {
      const scene = new FFScene();
      const image = new FFImage({ path: imagePath });
      scene.addChild(image);
      creator.addScene(scene);
    });

    videos.value.forEach((videoPath) => {
      const scene = new FFScene();
      const video = new FFVideo({ path: videoPath });
      scene.addChild(video);
      creator.addScene(scene);
    });

    const outputPath = path.join('./output', 'generated - video.mp4');
    await creator.output(outputPath);
    console.log('视频生成成功');
  } catch (err: any) {
    error.value = `视频生成失败: ${err.message}`;
    console.error(err);
  } finally {
    loading.value = false;
  }
};
</script>

4. 样式(App.vue

<style scoped>
#app {
  font - family: Avenir, Helvetica, Arial, sans - serif;
  -webkit - font - smoothing: antialiased;
  -moz - osx - font - smoothing: grayscale;
  text - align: center;
  color: #2c3e50;
  margin - top: 60px;
}
</style>

5.注意事项

  1. 文件 URL 管理URL.createObjectURL生成的 URL 需要在适当的时候释放,例如在组件卸载时,可以使用beforeUnmount钩子函数来 revoke 这些 URL。
  2. 错误处理:当前的错误处理只是简单地记录和显示错误信息,可以根据实际需求进行更详细的错误处理,如针对不同类型的 FFCreator 错误进行不同提示。
  3. FFCreator 功能扩展:FFCreator 提供了丰富的功能,如场景过渡、元素动画等,可以进一步扩展此项目,为用户提供更多的视频编辑选项。
  4. 文件存储与安全性:在实际应用中,建议将上传的文件存储到服务器上,并进行必要的安全性检查,以防止恶意文件上传。

2.批量视频生成

在一些内容创作平台或营销场景中,可能需要批量生成大量短视频。可以利用 FFCreator 结合数据库或文件系统,读取大量的素材数据和配置信息,循环调用 FFCreator 的 API 来批量生成视频,提高工作效率。

以下是一个利用 FFCreator 结合文件系统,根据配置信息批量生成视频的示例:

假设配置信息存储在一个 JSON 文件中,素材文件(图片、视频、音频)存储在特定目录下。

1.示例代码

const FFCreator = require('ffcreator');
const fs = require('fs');
const path = require('path');

// 读取配置文件
const configPath = path.join(__dirname, 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));

// 批量生成视频
config.forEach(async (videoConfig) => {
    const { width, height, fps, outputPath, scenes } = videoConfig;
    const creator = new FFCreator({
        width,
        height,
        fps,
        render: 'gl',
        outputDir: path.dirname(outputPath)
    });

    // 添加音频(如果有)
    if (videoConfig.audio) {
        const audioPath = path.join(__dirname, 'audio', videoConfig.audio);
        creator.addAudio({ path: audioPath });
    }

    // 添加场景
    scenes.forEach((scene) => {
        const ffScene = new FFCreator.FFScene();
        scene.elements.forEach((element) => {
            if (element.type === 'image') {
                const imagePath = path.join(__dirname, 'images', element.path);
                const img = new FFCreator.FFImage({ path: imagePath });
                // 设置元素的属性,如位置、缩放等
                if (element.x) img.setX(element.x);
                if (element.y) img.setY(element.y);
                if (element.scale) img.setScale(element.scale);
                ffScene.addChild(img);
            } else if (element.type === 'video') {
                const videoPath = path.join(__dirname, 'videos', element.path);
                const vid = new FFCreator.FFVideo({ path: videoPath });
                // 设置视频元素的属性,如开始时间、时长等
                if (element.startTime) vid.setStartTime(element.startTime);
                if (element.duration) vid.setDuration(element.duration);
                ffScene.addChild(vid);
            } else if (element.type === 'text') {
                const text = new FFCreator.FFText({ text: element.content });
                // 设置文本元素的属性,如颜色、位置等
                if (element.color) text.setColor(element.color);
                if (element.x) text.setX(element.x);
                if (element.y) text.setY(element.y);
                ffScene.addChild(text);
            }
        });
        // 设置场景过渡
        if (scene.transition) {
            ffScene.setTransition(scene.transition);
        }
        creator.addScene(ffScene);
    });

    try {
        await creator.output(outputPath);
        console.log(`视频 ${outputPath} 生成成功`);
    } catch (error) {
        console.error(`视频 ${outputPath} 生成失败:`, error);
    }
});

2.配置文件示例 (config.json)

[
    {
        "width": 1280,
        "height": 720,
        "fps": 30,
        "outputPath": "output/vid1.mp4",
        "audio": "audio1.mp3",
        "scenes": [
            {
                "transition": {
                    "type": "fade",
                    "duration": 2000
                },
                "elements": [
                    {
                        "type": "image",
                        "path": "image1.jpg",
                        "x": 100,
                        "y": 100,
                        "scale": 0.8
                    }
                ]
            },
            {
                "elements": [
                    {
                        "type": "video",
                        "path": "video1.mp4",
                        "startTime": 0,
                        "duration": 5
                    }
                ]
            },
            {
                "elements": [
                    {
                        "type": "text",
                        "content": "这是一段文字",
                        "color": "#ffffff",
                        "x": 500,
                        "y": 300
                    }
                ]
            }
        ]
    },
    {
        "width": 1920,
        "height": 1080,
        "fps": 25,
        "outputPath": "output/vid2.mp4",
        "scenes": [
            {
                "elements": [
                    {
                        "type": "image",
                        "path": "image2.jpg"
                    }
                ]
            }
        ]
    }
]

3.说明

  1. 配置文件config.json文件定义了每个视频的参数,包括视频尺寸、帧率、输出路径、音频文件以及每个场景的元素和过渡效果。
  2. 文件路径:代码假设素材文件(图片、视频、音频)分别存储在imagesvideosaudio目录下,根据实际情况调整路径。
  3. FFCreator 配置:根据配置信息初始化 FFCreator 实例,并添加音频、场景和元素。每个元素的属性根据配置进行设置。
  4. 错误处理:在生成视频时捕获错误,并打印错误信息,以便调试。

3.与其他工具集成

可以与图像编辑工具、音频处理工具等其他相关工具集成。例如,在生成视频前,先使用图像编辑工具对图片进行预处理,或者使用音频处理工具对音频进行混音、降噪等操作,然后再将处理后的素材交给 FFCreator 进行视频制作,以实现更复杂、更专业的视频制作需求。

Kuikly 原生 API 扩展机制对比总结

Kuikly 原生 API 扩展机制对比总结,小程序跳过

扩展原生API

1. Kuikly 侧(通用)

  • 核心类:继承 Module基类,实现 moduleName()方法。

  • 通信方式

    • 异步调用:通过 toNative方法传递 callbackFn接收返回值。
    • 同步调用:通过 toNative设置 syncCall=true直接返回结果。
  • 注册方式:在 Pager子类中重写 createExternalModules,以键值对形式注册模块(键名需与原生侧一致)。

  • 辅助方法:提供 syncToNativeMethodasyncToNativeMethod简化调用。

2. Android 侧

  • 核心类:继承 KuiklyRenderBaseModule,重写 call方法(区分参数类型)。

  • 方法实现

    • 通过 method参数匹配 Kuikly 侧方法名。
    • 无返回值方法:直接执行原生逻辑(如 Log.d)。
    • 异步回调:通过 KuiklyRenderCallback.invoke返回结果。
    • 同步返回:直接 return结果(如字符串)。
  • 注册方式:在 registerExternalModule中调用 moduleExport,键名需与 Kuikly 侧一致。

3. iOS 侧

  • 核心类:继承 KRBaseModule,类名必须与 Kuikly 侧注册名一致(如 KRMyLogModule)。

  • 方法实现

    • 方法名与 Kuikly 侧 toNativemethodName严格匹配。
    • 参数固定为 NSDictionary,通过 HR_PARAM_KEY提取参数。
    • 异步回调:从参数中获取 KuiklyRenderCallback并调用。
    • 同步返回:直接 return结果。
  • 特点:依赖运行时动态查找方法,需严格命名。

4. 鸿蒙侧(ArkTS)

  • 核心类:继承 KuiklyRenderBaseModule,需实现 syncMode()(决定是否支持同步调用)。

  • 方法实现

    • 通过 switch-case分发 method调用。
    • 异步回调:调用 callback函数返回结果。
    • 同步返回:直接 return结果。
  • 注册方式:在 getCustomRenderModuleCreatorRegisterMap中通过 Map注册模块。


关键对比点

基类 方法匹配方式 回调机制 同步支持 注册方式
Kuikly Module 方法名直接调用 CallbackFn或同步返回 是(syncCall createExternalModules映射注册
Android KuiklyRenderBaseModule call方法内 switch KuiklyRenderCallback.invoke moduleExport动态注册
iOS KRBaseModule 方法名动态反射 从参数提取 callback调用 类名必须与 Kuikly 侧一致(自动注册)
鸿蒙 KuiklyRenderBaseModule call方法内 switch 直接调用 callback参数 syncMode()返回 true Map键值对注册

注意事项

  1. 命名一致性:Kuikly 与原生侧的模块名、方法名必须严格一致(iOS 侧类名需完全匹配)。

  2. 线程模型

    • Android/iOS 异步回调默认在主线程,同步调用在子线程。
    • 鸿蒙需通过 syncMode()显式声明是否支持同步。
  3. 参数传递

    • 基本类型/数组可直接传递,JSON 需序列化为字符串。
    • iOS 参数统一封装为 NSDictionary,鸿蒙/Android 可灵活处理。

通过这套机制,Kuikly 实现了跨平台 API 的统一扩展,开发者只需遵循各端规范即可复用原生能力。

【TS 设计模式完全指南】从“入门”到“劝退”,彻底搞懂单例模式

一、 单例模式是什么?

保证一个类仅有一个实例,并提供一个全局访问点来获取这个实例。

二、 经典单例的 TypeScript 实现

要实现一个单例模式,我们需要做到三点:

  1. 构造函数必须是私有的 (private constructor),防止外部通过 new 关键字随意创建实例。
  2. 类内部需要持有一个静态的、私有的自身实例。
  3. 提供一个公开的、静态的方法 (getInstance),用于获取这个唯一的实例。
class AppConfig {
  // 1. 持有私有的静态实例
  private static instance: AppConfig;

  private config: Record<string, any>;

  // 2. 将构造函数私有化
  private constructor() {
    console.log("读取配置文件... (这只会被打印一次)");
    this.config = {
      version: "1.0.0",
      server: "https://api.example.com",
    };
  }

  public getConfig(key: string): any {
    return this.config[key];
  }

  // 3. 提供公开的静态方法获取实例
  public static getInstance(): AppConfig {
    if (!AppConfig.instance) {
      AppConfig.instance = new AppConfig();
    }
    return AppConfig.instance;
  }
}

// ---- 如何使用 ----

// const errorConfig = new AppConfig(); // ❌ 错误: "AppConfig" 的构造函数是私有的。TS 在编译期就阻止了你!

const config1 = AppConfig.getInstance();
const config2 = AppConfig.getInstance();

console.log(config1 === config2); // true,它们是同一个实例!

const serverUrl = config1.getConfig("server");
console.log(`服务器地址: ${serverUrl}`);

这就是经典的懒汉式单例(Lazy Initialization)。只有在第一次调用 getInstance() 时,实例才会被创建。TypeScript 的 private constructor 更是为我们提供了编译时的安全保障!

三、“饿汉式” vs “懒汉式”

除了上面的懒汉式,还有一种实现方式叫饿汉式。它在类加载时就立即创建实例,不管你用不用。

class EagerAppConfig {
    // 在类加载时就直接创建实例
    private static readonly instance: EagerAppConfig = new EagerAppConfig();

    private constructor() {
        console.log('饿汉式:配置文件已加载!');
    }

    public static getInstance(): EagerAppConfig {
        return EagerAppConfig.instance;
    }
}

// 即使还没调用 getInstance,构造函数里的 log 也会被打印出来
//const eagerConfig = EagerAppConfig.getInstance();

两者对比:

  • 懒汉式
    • 优点:延迟加载,节省资源。如果一直用不到这个实例,就不会创建它。
    • 缺点:在多线程环境下需要处理同步问题(不过在 JS/TS 的单线程事件循环模型中,实例化本身的竞态条件不是主要问题)。第一次获取实例时会稍慢。
  • 饿汉式
    • 优点:实现简单,天生线程安全。获取实例速度快。
    • 缺点:类加载时就初始化,可能造成资源浪费,尤其当实例创建很耗时,但应用又不一定会使用它时。

在 TS/JS 环境中,由于其模块加载机制,我们还有一种更简洁的“单例”实现方式。

四、JS/TS特有的单例模式实现

ES6 模块有一个重要特性:模块内的代码只会在第一次被导入时执行一次。之后再 import 同一个模块,只会得到缓存的导出结果。我们可以利用这个特性,轻松实现一个单例。

// logger.ts
class Logger {
    private logs: string[] = [];

    constructor() {
        console.log('Logger 初始化了!(只会发生一次)');
    }

    public log(message: string) {
        const timestamp = new Date().toISOString();
        this.logs.push(`[${timestamp}] ${message}`);
        console.log(`[Logger]: ${message}`);
    }

    public printLogs() {
        console.log(this.logs);
    }
}

// 直接实例化并导出
export const logger = new Logger();
// 这样在任何地方 import { logger } 都会得到同一个实例

// ---- 在其他文件中使用 ----
// a.ts
import { logger } from './logger';
logger.log("模块 A 的消息");

// b.ts
import { logger } from './logger';
logger.log("模块 B 的消息");
logger.printLogs(); // 会打印出模块 A 和 B 的两条消息

这种方式代码量最少,也最直观。它实际上是一种饿汉式的实现,非常适合那些创建开销不大且必定会被用到的场景。对于绝大多数 TS/JS 应用的简单全局实例需求,这通常是最佳选择

为了方便大家学习和实践,本文的所有示例代码和完整项目结构都已整理上传至我的 GitHub 仓库。欢迎大家克隆、研究、提出 Issue,共同进步!

📂 核心代码与完整示例: GoF

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript/TypeScript开发干货

深入JS(一):手写 Promise

0. 前言

Promise是我们经常用来管理和简化异步操作的对象,面试中我们经常会被问到相关的问题,比如结合事件循环机制来回答某一段代码的输出顺序,或者要求实现一个异步的任务管理函数,这些都是需要理解 Promise 的原理才能够有底气的回答。还有另一种常见的问题,就是手写 Promise 或者手写 Promise 的各个静态方法。

碰到手撕类的问题,如果我们没有充分准备或者阅读过 Promise 实现的源码,很容易就GG了,有些观点会提到说这种面试题很没有含金量,但是我认为了解如何实现 Promise 对我们的编码还是有很大帮助的,它可以帮助我们更好的理解 Promise 是如何使用统一的状态管理和链式调用机制来帮我们处理复杂任务。

这篇文章会结合 Promise/A+规范 来渐进式地实现我们自己的 Promise 类。

1. 实现 MyPromise 类

我们来分析一下如何使用 Promise :

  1. 每个 Promise 实例具有三种状态,分别是 PendingFulfilledRejected
  2. 在使用 Promise 的时候我们会传入一个接收 resolvereject 的回调函数,这个回调函数会被同步执行,我们可以在回调函数内部调用入参来修改当前 Promise 实例的状态,同时为了保证 Promise 的可预测性和确定性,我们只能修改一次状态。
  3. 我们可以使用实例的 then 方法,这个方法接收一个onFulfilledonRejected 回调函数用来处理结果或者错误

通过以上分析,我们可以轻松实现一个 Promise 类。

const PENDING = "PENDING";
const FULFILLED = "FULFILLED";
const REJECTED = "REJECTED";

class MyPromise {
  constructor(executor) {
    this.status = PENDING;
    this.value = undefined;
    this.reason = undefined;
    
    const resolve = (value) => {
      if (this.status === PENDING) {
        this.status = FULFILLED;
        this.value = value;
      }
    };

    const reject = (reason) => {
      if (this.status === PENDING) {
        this.status = REJECTED;
        this.reason = reason;
      }
    };
    
    // 立即执行我们传入的回调函数
    try {
      executor(resolve, reject)
    } catch (e) {
      reject(e)
    }
  }
  
  then(onFulfilled, onRejected){
    // 根据当前状态选择执行相应的处理函数
    if (this.status === FULFILLED) {
      onFulfilled(this.value)
    }
    if (this.status === REJECTED) {
      onRejected(this.reason)
    }
  }
}

我们初步实现了 MyPromise 类,现在用一些代码来测试一下

// test1
new MyPromise((resolve, reject) => {
   resolve("1");
}).then(
  (res) => {
    console.log("success", res);
  },
  (err) => {
    console.log("failed", err);
  }
);

// test2
new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve("1");
  }, 1000);
}).then(
  (res) => {
    console.log("success", res);
  },
  (err) => {
    console.log("failed", err);
  }
);

我们发现 test1 中控制台会成功输出"success 1",而 test2 则毫无反应,这是因为我们的 then 实现中只处理了状态已经被敲定的情况,而对于 test2 这种状态异步敲定的情况则未做处理,我们可以通过一个数组来暂存传入的处理函数,在状态敲定时去清空暂存数组来实现。

分析完问题我们来修改一下代码。

class MyPromise {
  constructor(executor) {
    this.status = PENDING;
    this.value = undefined;
    this.reason = undefined;
    // 定义回调暂存队列
    this.onFulfilledCallbackQueue = []
    this.onRejectedCallbackQueue = []
    
    const resolve = (value) => {
      if (this.status === PENDING) {
        this.status = FULFILLED;
        this.value = value;
        
        // 状态敲定时清空对应的队列
        this.onFulfilledCallbackQueue.forEach(fn => fn())
      }
    };

    const reject = (reason) => {
      if (this.status === PENDING) {
        this.status = REJECTED;
        this.reason = reason;
        
        // 状态敲定时清空对应的队列
        this.onRejectedCallbackQueue.forEach(fn => fn())
      }
    };
    
    // 立即执行我们传入的回调函数
    try {
      executor(resolve, reject)
    } catch (e) {
      reject(e)
    }
  }
  
  then(onFulfilled, onRejected){
    // 根据当前状态选择执行相应的处理函数
    if (this.status === FULFILLED) {
      onFulfilled(this.value)
    }
    if (this.status === REJECTED) {
      onRejected(this.reason)
    }
    
    // 状态为 pending 时暂存回调函数
    if (this.status === PENDING) {
      this.onFulfilledCallbackQueue.push(() => {
        onFulfilled(this.value)
      })
      this.onRejectedCallbackQueue.push(() => {
        onRejected(this.reason)
      })
    }
  }
}

重新测试我们的 test2,我们发现控制台在 1s 后成功打印了内容,到此我们已经实现了一个 Promise 类的基本功能。

2. 链式调用

a. 实现

我们知道,then 方法是支持链式调用的,同时值要在链式调用时往下传递,我们很容易想到一个解决办法:将 then 方法的返回值设置为一个我们的 MyPromise 实例,我们来尝试一下。

class MyPromise {
  constructor(executor) {
    this.status = PENDING;
    this.value = undefined;
    this.reason = undefined;
    // 定义回调暂存队列
    this.onFulfilledCallbackQueue = []
    this.onRejectedCallbackQueue = []
    
    const resolve = (value) => {
      if (this.status === PENDING) {
        this.status = FULFILLED;
        this.value = value;
        
        // 状态敲定时清空对应的队列
        this.onFulfilledCallbackQueue.forEach(fn => fn())
      }
    };

    const reject = (reason) => {
      if (this.status === PENDING) {
        this.status = REJECTED;
        this.reason = reason;
        
        // 状态敲定时清空对应的队列
        this.onRejectedCallbackQueue.forEach(fn => fn())
      }
    };
    
    // 立即执行我们传入的回调函数
    try {
      executor(resolve, reject)
    } catch (e) {
      reject(e)
    }
  }
  
  then(onFulfilled, onRejected){
    const promise2 = new MyPromise((resolve, reject) => {
      const handleFulfilled = () => {
        try {
          const x = onFulfilled(this.value)
          resolve(x)
        } catch (e) {
          reject(e)
        }
      }
      
      const handleRejected = () => {
        try {
          const x = onRejected(this.reason)
          // onRejected 是用来处理错误的,想象一下我们有这样一个流程:在 promise 敲定后,如果出现错误,在错误处理函数中修正错误使链式调用能够继续
          // 所以这里调用的是 resolve 而不是 reject
          // fetch('http://bad-url.com')
          //  .then(
          //    response => response.json(),
          //    error => {
          //      console.error('网络请求失败,使用默认数据:', error);
          //      return { status: 'offline', data: 'N/A' }; // 恢复 Promise 链
          //    }
          //  )
          //  .then(data => {
          //    console.log('处理数据:', data);
          //  });
          resolve(x)
        } catch (e) {
          reject(e)
        }
      }
      // 根据当前状态选择执行相应的处理函数
      if (this.status === FULFILLED) {
        handleFulfilled()
      }
      if (this.status === REJECTED) {
        handleRejected()
      }

      // 状态为 pending 时暂存回调函数
      if (this.status === PENDING) {
        this.onFulfilledCallbackQueue.push(() => {
          handleFulfilled()
        })
        this.onRejectedCallbackQueue.push(() => {
          handleRejected()
        })
      }
    })
    
    return promise2
  }
}

我们来测试一下上面的代码

// test3
new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve("结果1");
  }, 1000);
})
  .then(
    (res) => {
      console.log("success 1", res);
      return "结果2";
    },
    (err) => {
      console.log("failed", err);
      return "修复的结果2"
    }
  )
  .then(
    (res) => {
      console.log("success 2", res);
    },
    (err) => {
      console.log("failed", err);
    }
  );

// test4
new MyPromise((resolve, reject) => {
  setTimeout(() => {
    reject("结果1");
  }, 1000);
})
  .then(
    (res) => {
      console.log("success 1", res);
      return "结果2";
    },
    (err) => {
      console.log("failed", err);
      return "修复的结果2"
    }
  )
  .then(
    (res) => {
      console.log("success 2", res);
    },
    (err) => {
      console.log("failed", err);
    }
  );

我们运行测试代码,发现输出和我们预期的一样,这样就解决了.then 的链式调用......了吗?

b. 问题

想象一个场景,第一个 Promise 中我们处理的是 用户登录请求,然后第一个 then 中我们根据前面的请求响应的 用户ID 来向服务端请求 用户详细信息 ,第二个 then 中我们根据请求到的详细信息来修改 UI 状态。

// test5
new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve("登录信息");
  }, 1000);
})
  .then(
    (userId) => {
      // 根据登录得到的id来请求用户信息
      return new MyPromise((resolve, reject) => {
        setTimeout(() => {
            resolve("用户信息");
        }, 1000);
      })
    },
    (err) => {
      console.log("failed", err);
    }
  )
  .then(
    (userInfo) => {
      console.log("根据获得的结果修改 UI,结果:", userInfo);
    },
    (err) => {
      console.log("failed", err);
    }
  );

我们运行 test5 这段测试代码,发现最后控制台打印出来的结果如下

根据获得的结果修改 UI,结果: MyPromise {
  status: 'PENDING',
  value: undefined,
  reason: undefined,
  onFulfilledCallbackQueue: [],
  onRejectedCallbackQueue: []
}

这显然和我们得到用户信息的预期相去甚远,分析一下测试代码,我们可以发现原因是第一个 then 中返回的是一个新的实例。查阅一下 MDN 对 Promise.then 方法的返回值的的描述,其中第4、5、6点提到了返回新的 Promise 实例的情况。

image-20250907152115423

3. 根据规范实现链式调用

根据以上描述我们可以得知,我们在 then 中返回的实例(代码里的 promise2)是要根据不同的返回值做出不同的处理,那么这中间又会涉及到很多的情况,如果我们刚开始接触相关的知识学习,很难去理清所有的情况。但是! Promise/A+规范 为我们提供了充足的指导,它是一个由实现者制定,为实现者服务的开放的标准,用于实现互操作的JavaScript Promise。

a. then 方法

我们直接找到描述实现then的这一节,参照着规范的描述来修改我们的 then 方法

...
then(onFulfilled, onRejected) {
    // 2.2.1 Both onFulfilled and onRejected are optional arguments
    // 2.2.7.3 If onFulfilled is not a function and promise1 is fulfilled, promise2 must be fulfilled with the same value as promise1
    // 2.2.7.4 If either onFulfilled or onRejected throws an exception e, promise2 must be rejected with e as the reason.
    // 这几条规则明确了传入的回调函数是可选的,如果未传入相关参数,我们需要给这回调函数设置默认值来穿透行为
    // .then().then().then((res) => console.log(res))
    onFulfilled = typeof onFulfilled === "function" ? onFulfilled : (x) => x;
    onRejected =
      typeof onRejected === "function"
        ? onRejected
        : (x) => {
            throw x;
          };

    const promise2 = new MyPromise((resolve, reject) => {
      const handleFulfilled = () => {
        // 2.2.4 onFulfilled or onRejected must not be called until the execution context stack contains only platform code.
        // 这条规定了传入 then 的回调函数应该被异步执行,我们这里使用 setTimeout 模拟实现
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value);
            // 2.2.7.1 If either onFulfilled or onRejected returns a value x, run the Promise Resolution Procedure [[Resolve]](promise2, x).
            // 这条规定了我们要实现一个处理函数,将回调函数的返回值作为参数处理我们的回调函数
            // run [[Resolve]](promise2, x)
          } catch (e) {
            // 2.2.7.2 If either onFulfilled or onRejected throws an exception e, promise2 must be rejected with e as the reason.
            reject(e);
          }
        }, 0);
      };

      const handleRejected = () => {
        // 2.2.4 onFulfilled or onRejected must not be called until the execution context stack contains only platform code.
        setTimeout(() => {
          try {
            const x = onRejected(this.reason);
            // run [[Resolve]](promise2, x)
          } catch (e) {
            reject(e);
          }
        }, 0);
      };

      if (this.status === FULFILLED) {
        handleFulfilled();
      }

      if (this.status === REJECTED) {
        handleRejected();
      }

      if (this.status === PENDING) {
        // 2.2.6.1 If/when promise is fulfilled, all respective onFulfilled callbacks must execute in the order of their originating calls to then.
        // 对应着我们的回调暂存队列,队列的性质是先进先出,在实现中我们直接通过 forEach 从前往后遍历
        this.onFulfilledCallbackQueue.push(() => {
          handleFulfilled();
        });
        
        // 2.2.6.2 If/when promise is rejected, all respective onRejected callbacks must execute in the order of their originating calls to then.
        this.onRejectedCallbackQueue.push(() => {
          handleRejected();
        });
      }
    });

    // 2.2.7 then must return a promise
    return promise2;
  }

通过阅读规则,我们知道了需要实现一个 Promise 解决程序来处理回调函数的返回值,接下来我们就根据规范来实现这个函数。

b. Promise 解决程序

查阅规范我们得知,"Promise 解决程序"是一项抽象操作,它接受一个 Promise 和一个值 x 作为输入,表示为 [[Resolve]](promise, x)。如果 x 是一个 thenable 对象,该程序会尝试让 promise 采用 x 的状态,前提是 x 的行为至少在某种程度上类似于一个 Promise。否则,它将以值 x 来完成(fulfilled)promise。这种对 thenable 的处理方式,使得不同的 Promise 实现能够互相操作,只要它们都暴露一个符合 Promises/A+ 规范的 then 方法。这也让符合 Promises/A+ 规范的实现,能够‘同化’那些行为合理但并不完全遵循规范的实现。

这里实际上就是采用了一个适配器模式,只要 x 实现了规范的 then 方法,则可以被 Promise 链吸收,比如说我们在使用多个第三方库的时候,每个库封装了不同的操作,但是都实现了 then 方法,那么我们就可以在同一个链中无痛使用他们。

我们通过函数来实现这个解决程序。

const resolvePromise = (promise, x, resolve, reject) => {
  // 2.3.1 If promise and x refer to the same object, reject promise with a TypeError as the reason.
  if (promise === x) {
    return reject(new TypeError("循环引用"));
  }

  // 2.3.3.3.3 If both resolvePromise and rejectPromise are called, or multiple calls to the same argument are made, the first call takes precedence, and any further calls are ignored.
  // const racyThenable = {
  //   then(resolve, reject) {
  //     // 同步调用 resolve
  //     resolve('成功')
  //     throw new Error('resolve后的异常')
  //   }
  // }
  // 不判断 called 的话先被 resolve 然后又会被 catch 捕获调用 reject
  let called;
  // 2.3.3 Otherwise, if x is an object or function,
  if (typeof x === "function" || (typeof x === "object" && x !== null)) {
    try {
      // 2.3.3.1 Let then be x.then
      // 避免 getter 产生副作用
      let then = x.then;

      // 2.3.3.3
      // If then is a function, call it with x as this, first argument resolvePromise, and second argument rejectPromise, where
      if (typeof then === "function") {
        then.call(
          x,
          // 2.3.3.3.1
          // If/when resolvePromise is called with a value y, run [[Resolve]](promise, y).
          // 递归调用解决函数
          (y) => {
            if (called) return;
            called = true;
            // 递归调用
            resolvePromise(promise, y, resolve, reject);
          },
          // 2.3.3.3.2
          // If/when rejectPromise is called with a reason r, reject promise with r
          (r) => {
            if (called) return;
            called = true;
            reject(r);
          }
        );
      } else {
        // 2.3.3.4 If then is not a function, fulfill promise with x.
        resolve(x);
      }
    } catch (e) {
      // 2.3.3.3.4
      // If calling then throws an exception e
      // 2.3.3.3.4.1
      // If resolvePromise or rejectPromise have been called, ignore it.
      // 2.3.3.3.4.2
      // Otherwise, reject promise with e as the reason.

      // 2.3.3.2 If retrieving the property x.then results in a thrown exception e, reject promise with e as the reason.

      // 这两条规范都收敛到同一个 catch 中实现了
      if (called) return;
      called = true;
      reject(e);
    }
  } else {
    // 2.3.4 If x is not an object or function, fulfill promise with x.
    resolve(x);
  }
};

我们测试一下上面的 test5,执行后发现控制台的打印如下:

根据获得的结果修改 UI,结果: 用户信息

得到的结果符合我们的预期,如果要简化的理解 resolvePromise 的作用,我认为它起到的作用就是从thenable 对象中解包我们真正需要的返回值。

到此我们实现了 Promise 的核心功能,我们可以通过promises-aplus-tests 库来验证一下我们的 Promise 是否符合规范。

完整代码如下

const PENDING = "PENDING";
const FULFILLED = "FULFILLED";
const REJECTED = "REJECTED";

const resolvePromise = (promise, x, resolve, reject) => {
  // 2.3.1 If promise and x refer to the same object, reject promise with a TypeError as the reason.
  if (promise === x) {
    return reject(new TypeError("循环引用"));
  }

  // 2.3.3.3.3 If both resolvePromise and rejectPromise are called, or multiple calls to the same argument are made, the first call takes precedence, and any further calls are ignored.
  // const racyThenable = {
  //   then(resolve, reject) {
  //     // 同步调用 resolve
  //     resolve('成功')
  //     throw new Error('resolve后的异常')
  //   }
  // }
  // 不判断 called 的话先被 resolve 然后又会被 catch 捕获调用 reject
  let called;
  // 2.3.3 Otherwise, if x is an object or function,
  if (typeof x === "function" || (typeof x === "object" && x !== null)) {
    try {
      // 2.3.3.1 Let then be x.then
      // 避免 getter 产生副作用
      let then = x.then;

      // 2.3.3.3
      // If then is a function, call it with x as this, first argument resolvePromise, and second argument rejectPromise, where
      if (typeof then === "function") {
        then.call(
          x,
          // 2.3.3.3.1
          // If/when resolvePromise is called with a value y, run [[Resolve]](promise, y).
          (y) => {
            if (called) return;
            called = true;
            resolvePromise(promise, y, resolve, reject);
          },
          // 2.3.3.3.2
          // If/when rejectPromise is called with a reason r, reject promise with r
          (r) => {
            if (called) return;
            called = true;
            reject(r);
          }
        );
      } else {
        // 2.3.3.4 If then is not a function, fulfill promise with x.
        resolve(x);
      }
    } catch (e) {
      // 2.3.3.3.4
      // If calling then throws an exception e
      // 2.3.3.3.4.1
      // If resolvePromise or rejectPromise have been called, ignore it.
      // 2.3.3.3.4.2
      // Otherwise, reject promise with e as the reason.

      // 2.3.3.2 If retrieving the property x.then results in a thrown exception e, reject promise with e as the reason.

      // 这两个规范都收敛到同一个 catch 中实现了
      if (called) return;
      called = true;
      reject(e);
    }
  } else {
    // 2.3.4 If x is not an object or function, fulfill promise with x.
    resolve(x);
  }
};

class MyPromise {
  constructor(executor) {
    this.status = PENDING;
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbackQueue = [];
    this.onRejectedCallbackQueue = [];

    const resolve = (value) => {
      if (this.status === PENDING) {
        this.status = FULFILLED;
        this.value = value;

        this.onFulfilledCallbackQueue.forEach((fn) => fn());
      }
    };

    const reject = (reason) => {
      if (this.status === PENDING) {
        this.status = REJECTED;
        this.reason = reason;

        this.onRejectedCallbackQueue.forEach((fn) => fn());
      }
    };

    // 立即执行 executor
    try {
      executor(resolve, reject);
    } catch (e) {
      reject(e);
    }
  }

  then(onFulfilled, onRejected) {
    // 2.2.7.3 If onFulfilled is not a function and promise1 is fulfilled, promise2 must be fulfilled with the same value as promise1
    onFulfilled = typeof onFulfilled === "function" ? onFulfilled : (x) => x;

    // 2.2.7.4 If either onFulfilled or onRejected throws an exception e, promise2 must be rejected with e as the reason.
    onRejected =
      typeof onRejected === "function"
        ? onRejected
        : (x) => {
            throw x;
          };

    const promise2 = new MyPromise((resolve, reject) => {
      const handleFulfilled = () => {
        // 2.2.4 onFulfilled or onRejected must not be called until the execution context stack contains only platform code.
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value);
            resolvePromise(promise2, x, resolve, reject);
          } catch (e) {
            reject(e);
          }
        }, 0);
      };

      const handleRejected = () => {
        // 2.2.4 onFulfilled or onRejected must not be called until the execution context stack contains only platform code.
        setTimeout(() => {
          try {
            const x = onRejected(this.reason);
            resolvePromise(promise2, x, resolve, reject);
          } catch (e) {
            reject(e);
          }
        }, 0);
      };

      if (this.status === FULFILLED) {
        handleFulfilled();
      }

      if (this.status === REJECTED) {
        handleRejected();
      }

      if (this.status === PENDING) {
        this.onFulfilledCallbackQueue.push(() => {
          handleFulfilled();
        });

        this.onRejectedCallbackQueue.push(() => {
          handleRejected();
        });
      }
    });

    // 2.2.7 then must return a promise
    return promise2;
  }
}

MyPromise.deferred = function () {
  var result = {};
  result.promise = new MyPromise(function (resolve, reject) {
    result.resolve = resolve;
    result.reject = reject;
  });

  return result;
}
module.exports = MyPromise;

我们在命令行运行 npx promises-aplus-tests 文件路径,可以看到控制台的输出如下

image-20250907180320328

我们成功通过了所有的用例,证明我们的实现是符合规范的。

4. 小结

通过一些测试用例和查阅规范,我们由浅入深地实现了一个 Promise 类。理解了中间的原理之后,其他的静态方法实现起来也很简单,我们可以参考 MDN 上各个静态方法的定义来实现功能,这里不做赘述。

参考文章:

面试官:“你能手写一个 Promise 吗”

从一道让我失眠的 Promise 面试题开始,深入分析 Promise 实现细节

一个前端开发者的救赎之路-JS基础回顾(五)-数组

一: 创建数组

1. 数组字面量

let a = []
var b = [1, 'a', true]

注意: 还有一个稀疏数组,反正我没用过,工作中也很少见人用,大多数规范都不让用稀疏数组

2. 对可迭代对象使用...扩展操作符(ES6)

2.1 可迭代对象

  • 可迭代对象是指可以用for/of循环遍历的对象,如数组、字符串,集合和映射等

2.2 ...扩展操作符

  • ES2018以后,...扩展操作符在对象字面量也可以使用了
  • 出现在等号右边或参数位置的 ... 通常是展开(拆开)。
  • 出现在等号左边或参数声明的 ... 通常是剩余(收集)。

2.3 扩展操作符是创建数组(浅)副本的一种便携方式:(浅拷贝)

let originalArr = [1,2,3];
let copyArr = [...origonalArr];
copyArr[0] = 0;    // 修改copyArr不会影响originalArr
originalArr[0]      // => 1


const original = { hobbies: ['reading', 'swimming'] };
const copy = { ...original }; // 浅拷贝

// 修改嵌套数组中的元素(修改第二层)
copy.hobbies[0] = 'gaming';

console.log(original.hobbies); // 输出: ['gaming', 'swimming'] (被影响了!)
console.log(copy.hobbies);     // 输出: ['gaming', 'swimming']

3. Array()构造函数

3.1 不传参调用

  • let a = new Array(); 这样会创建一个没有元素的空数组,等价于字面量[]

3.2 传入一个数组参数,指定长度:

  • let a = new Array(10);
  • 这样会创建一个指定长度的数组。
  • 如果提前知道需要多少个数组元素,可以这样做来预先为数组分配空间
  • 注意:这时的数组中不会存储任何值,数组索引属性"0", "1"等甚至都没有定义

3.3 传入两个或更多个数组元素,或传入一个非数值元素

  • 这样调用的话,构造函数的参数会成为新数组的元素。使用数组字面量永远比这种方法简单。
// [5, 4, 3, 2, 1, 'testing, testing']
let a = new Array(5, 4, 3, 2, 1, "testing, testing")
// ['sddsdsdsd']
let b = new Array('sddsdsdsd')

3.4 工厂方法Array.of()和Array.from()

  1. Array.of()
    • 解决了Array()在使用数值参数时,如果只有一个参数,这个参数指定的是数组的长度,多个又变成了数组元素

    • Array.of(),可以使其参数值(无论多少个)多为数组的元素来创建并返回新数组

      Array.of([1,2,3]); // [[1,2,3]]
      Array.of(3);       // [3]
      
  2. Array.from()
    • 这个方法就是将一个类数组对象或者一个可迭代对象转换成新数组,如果传入的是可迭代对象,那他就和使用...扩展操作符操作一样
    • Array.from()定义了一种给类数组对象创建真正的数组副本的机制

二、数组的增删改查

1. 读写

  • []操作符中间包裹一个索引
  • 由于数组索引其实就是一种特殊的对象属性,所以JavaScript数组没有所谓的“越界”错误。查询任何对象中不存在的属性都不会导致错误,只会返回undefined。数组作为一种特殊对象也是如此。

2. 数组的长度

  • 每个数组都有length属性,正是这个属性让数组有别于常规的JavaScript对象,对于非稀疏数组,length属性就是数组中元素的个数。这个值比数组的最高索引大1

3. 增删

3.1 添加

  • 使用一个新索引赋值:例如:arr[arr.length] = 0
  • push(): 等同于arr[arr.length],末尾追加
  • unshift(): 从开头追加

3.2 删除

  • 可以使用delete操作符
let a = [1,2,3];
delete a[2];    // 现在索引2没有元素了
2 in a;         // => false: 数组索引2没有定义
a.length;       // => 3: 删除元素不影响数组长度
  • 把数组length设置成一个新长度值,也可以从末尾删除元素
  • splice()是一个可以插入,删除或替换数组元素的通用方法
  • pop()删除最后的元素,并返回删除值
  • shift()删除第一个元素,并返回删除值

三、数组的方法

1 迭代方法(循环)

简介

首先,所有这些方法都接收一个函数作为第一个参数,并且对数组的每一个元素(或某些元素)都调用一次这个函数。如果数组是稀疏的,则不会对不存在的元素调用传入这个函数。多数情况下,我们提供的这个函数被调用时都会接收到3个参数,分别是数组元素的值数组元素的索引数组本身通常我们只需要这几个参数中的第一个,可以忽略第二和第三个值。

多数迭代器方法都接收可选的第二个参数。如果指定这个参数,则第一个函数在被调用时就好像它是第二个参数的方法一样。换句话说,我们传入的第二个参数会成为作为第一个参数传入的函数内部的this值。传入函数的返回值通常不重要,但不同的方法会以不同的方式处理这个返回值。本节介绍的所有方法都不会修改调用它们的数组。(当然,传入的函数可能会修改这个数组)

forEach()

注意:forEach()并未提供一种提前终止迭代的方式。换句话说,在这里没有常规for循环中的break语句对等的机制。

map()

  • map()方法把调用它的数组的每个元素分别传给我们指定的函数,返回这个函数的返回值构成的数组。
  • 对于map()方法来说,我们传入的函数应该有返回值
  • 注意:map()返回一个新数组,并不修改原数组
  • 如果数组是稀疏的,则缺失的元素不会调用我们的函数,但返回的数组也会与原始数组一样稀疏:长度相同,缺失的元素也相同。

filter()

  • filter()方法返回一个数组,该数组包含调用它的数组的子数组
  • 传给这个方法的函数应该是断言函数即返回true或false的函数。这个函数与传给forEach()和map()的函数一样被调用。如果函数返回true或返回值能转换为true,则传给这个函数的的元素就是filter最终返回的子数组的成员
  • 注意:filter()会跳过稀疏数组中缺失的元素,它返回的数组始终是稠密的。因此可以使用该方法清掉稀疏数组中的空隙
  • 用自己的话来说,这就是一个过滤函数,返回一个包含满足条件元素的数组

find()与findIndex()

  • find(),在找到满足条件的第一个元素时停止迭代,返回匹配的值;找不到满足条件的元素,返回undefined。
  • findIndex(),在找到满足条件的第一个元素时停止迭代,返回匹配的值的索引;找不到满足条件的元素,返回-1。

every()与some()

  • every(),类似数学上的“全称”量词∀类似,它在且只在所有元素都满足断言函数的时候,才返回true
  • some(),类似数学上的“存在”量词∃类似,它是只要有一个元素满足断言函数的时候,就返回true,但必须所有元素都不满足的时候才返回false
  • 注意: some()遇到第一个返回true的就会停止迭代。同样,every()遇到第一个返回false的也会停止迭代。
  • 注意: 如果空数组调用它们,every()返回true,some()返回false

reduce()与reduceRight()

  • reduce()和reduceRight()方法使用我们指定的函数归并数组元素,最终产生一个值。

  • reduce()接收两个参数。第一个是执行归并的函数。第二个参数是可选的,是传给归并函数的初始值。

  • 在reduce()中使用的函数与在forEach()和map()中使用的函数不一样。我们熟悉的值、索引和数组本身在这里作为第二、第三和第四参数。第一个参数是目前为止归并操作的累积结果。

  • 如果reduce()调用时未传第二个参数,那么数组的第一个元素会被作为初始值

  • 如果不传初始值,在空数组上调用reduce()会导致TypeError。如果调用它时只有一个值,或者用空数组调用但传了初始值,则reduce直接返回这个值,不会调用归并函数

  • reduceRight()与reduce()类似,只不过从高索引向低索引(从右向左)处理数组,而不是从低向高。如果归并操作具有从右到左的结合性,那可能要考虑使用reduceRight(), 比如:

    // 计算2^(3^4)。求幂具有从右到左的优先级
    let a = [2, 3, 4]
    a.reduceRight((acc, val) => Math.pow(val, acc))
    
  • 注意: 无论reduce()还是reduceRight()都不接收用于指定归并函数this值的可选参数。它们用可选的初始值参数取代了这个值。如果需要可以考虑bind()方法

2. 使用flat()和flatMap()打平数组

  • flat()只能打平一级

    [1, 2, [3, 4, [5]]].flat() // =>[1, 2, 3, 4, [5]]
    
  • flatMap()方法与map()方法类似,只不过返回的数组会自动被打平,就像传给了flat()一样。换句话说,调用a.flatMap(f)等同于(但效率远高于)a.map(f).flat()

深入理解:Webpack编译原理

WebPack是什么?

Webpack是基于模块化的打包(构建)工具,它把一切视为模块

它通过一个开发时态的入口模块为起点,分析出所有的依赖关系,然后经过一系列的过程(压缩,合并),最终生成运行时状态的文件。

image.png

Webpack的特点:

  • 为前端工程化而生:webpack致力于解决前端工程化,特别是浏览器端工程化中遇到的问题,让开发者集中注意力编写业务代码,而把工程化过程中的问题全部交给webpack来处理

  • 简单易用:支持零配置,可以不用写任何一行额外的代码就使用webpack

  • 强大的生态:webpack是非常灵活、可以扩展的,webpack本身的功能并不多,但它提供了一些可以扩展其功能的机制,使得一些第三方库可以融于到webpack中

  • 基于nodejs:由于webpack在构建的过程(基于node)中需要读取文件,因此它是运行在node环境中的

  • 基于模块化:webpack在构建过程中要分析依赖关系,方式是通过模块化导入语句进行分析的,它支持各种模块化标准,包括但不限于CommonJS、ES6 Module

Webpack编译原理

Webpack的作用是源代码编译(构建,打包)成最终代码

image.png

👆上面的图片可见,我们开发时态和运行时态中间的部分是Webpack构建工具,那webpack 的编译过程是什么样的呢???

image.png

Webpack的编译构建过程,大致分为三个步骤:

image.png

三个过程分别,在做什么?

image.png

一 、 初始化

此阶段,webpack会将CLI参数、配置文件、默认配置进行融合,形成一个最终的配置对象。

对配置的处理过程是依托一个第三方库yargs完成的

此阶段相对比较简单,主要是为接下来的编译阶段做必要的准备

目前,可以简单的理解为,初始化阶段主要用于产生一个最终的配置

二、 编译

第一步:创建chunk

根据入口模块(默认为./src/index.js)创建一个chunk

chunk是webpack在内部构建过程中的一个概念,译为,它表示通过某个入口找到的所有依赖的统称。

每个chunk都有至少两个属性:

  • name:默认为main
  • id:唯一编号,开发环境和name相同,生产环境是一个数字,从0开始
image.png

第二步:构建所有依赖模块

image.png

此图中,构建所有依赖模块的时,根据入口文件,构建出所有模块

那么他是如何去构建这些模块的呢? 看图就立刻明白他的原理!!

image.png

它构建依赖模块的完整流程,可以拆解为以下几个清晰步骤,按顺序逐步推进:

1. 从 “入口文件” 开始,先查 “模块记录”

Webpack 会以开发者指定的 “入口模块”(比如 src/index.js)为起点,第一步先检查内部的 “模块记录”—— 这个记录就像一张 “已处理模块清单”。
如果入口文件已经在清单里,说明之前已经处理过,直接返回结果,不用重复执行后续步骤;如果不在清单里,就进入下一个环节。

2. 读文件、分析语法,找出 “依赖关系”

接下来 Webpack 会读取入口文件的内容,然后通过语法分析工具把代码转换成 “AST 抽象语法树”(可以理解为把代码拆成计算机能看懂的 “结构化图纸”)。
通过分析这张 “图纸”,Webpack 能精准找出当前模块依赖的其他模块(比如代码里的 import 或 require 语句),并把这些依赖信息统一保存到 dependencies(依赖列表)里。

3. 改代码、存模块,标记 “已处理”

为了让后续步骤能正确识别依赖,Webpack 会对当前模块的代码做一点 “改造”—— 比如替换掉 import 这类开发时的模块化语法,换成它能识别的内部函数。
改造后的代码会被保存起来,同时把当前模块加入 “模块记录” 和 “chunk 模块”(chunk 是 Webpack 临时用来整合模块的容器),标记为 “已处理”,避免重复处理。

4. 递归加载依赖,直到 “无遗漏”

最后,Webpack 会拿着 dependencies 里的依赖列表,逐个对这些依赖模块重复上面的 1-3 步:查记录→读文件→析依赖→改代码→存模块。
这个 “递归加载” 的过程会一直持续,直到所有依赖的模块(包括依赖的依赖)都被处理完,最终形成一个完整的依赖树。

第三步:产生chunk assets

在第二步完成后,chunk中会产生一个模块列表,列表中包含了模块id和模块转换后的代码

接下来,webpack会根据配置为chunk生成一个资源列表,即chunk assets,资源列表可以理解为是生成到最终文件的文件名和文件内容

image.png

chunk hash是根据所有chunk assets的内容生成的一个hash字符串

hash:一种算法,具体有很多分类,特点是将一个任意长度的字符串转换为一个固定长度的字符串,而且可以保证原始内容不变,产生的hash字符串就不变

第四步:合并chunk assets

将多个chunk的assets合并到一起,并产生一个总的hash

我们平常使用的前端框架,基本情况下,入口文件是一个的所以,很容易误解为,入口文件只能一个的想法

但是入口文件可以有多个的,所以chunk也会存在多个的时候;

image.png

三、 输出

此步骤非常简单,webpack将利用node中的fs模块(文件处理模块),根据编译产生的总的assets,生成相应的文件。

image.png

总过程

image.png

涉及术语

  1. module:模块,分割的代码单元,webpack中的模块可以是任何内容的文件,不仅限于JS
  2. chunk:webpack内部构建模块的块,一个chunk中包含多个模块,这些模块是从入口模块通过依赖分析得来的
  3. bundle:chunk构建好模块后会生成chunk的资源清单,清单中的每一项就是一个bundle,可以认为bundle就是最终生成的文件
  4. hash:最终的资源清单所有内容联合生成的hash值
  5. chunkhash:chunk生成的资源清单内容联合生成的hash值
  6. chunkname:chunk的名称,如果没有配置则使用main
  7. id:通常指chunk的唯一编号,如果在开发环境下构建,和chunkname相同;如果是生产环境下构建,则使用一个从0开始的数字进行编号

上期知识点

上期知识点:你知道Webpack解决的问题是什么嘛?

跨端技术:浅聊双线程原理和实现

小狐狸是什么:希望技术文章更有趣。我引入动物小伙伴作为我的同事们,一起探究代码和数据之美。希望之后出一本连年轻的小伙伴(小学生)都能懂技术故事书。

今天一进门,我看见小狐狸抱着她的三角脑袋在沉思。一边沉思一边还低声念叨。我倒了杯咖啡给她,询问她怎么回事。她看见我问她,耳朵耷拉下来了。我追问怎么回事,她用略带责备的口吻说,“到底为什么小程序要双线程这种逻辑,搞得麻烦死了。一点都不好用。还不如逻辑和渲染走一个线程”。

我把咖啡杯往她面前摞一摞说,你知道微信的双线程设计是怎么样的吗。她嘬了一口咖啡,打开了微信的官方文档。

developers.weixin.qq.com/ebook?actio…

她尤其指了一下下方这个架构图。

相比于浏览器的执行,webview(渲染) 和 JsCore (逻辑)是单独运行在两个线程,甚至可以是两个进程上的。JScore 可以有很多种实现方式。Web worker 就可以是一个 JSCore。JSCore 只需要提供一个标准的 JS 运行时环境即可。

小狐狸继续抱怨,我没懂为什么要这样设计。我思忖了一下,说你知道为什么 React 要设计 fiber 吗。狐狸说,因为要拆小逻辑的执行任务,减少单一任务过长导致渲染任务执行。那么双线程不就没这个问题了呗,我断言到。小狐狸点点头。我继续引导道,更多的进程和线程对操作系统意味着什么。更多的计算资源和存储资源可以用来做之前事情。事实上,渲染和逻辑分离可能才是最佳的应用解决方案。而非浏览器的单线程方案。

小狐狸沉下问,那么是不是我们需要大面积的改变我们的 H5 写法。来适应双线程的开发方式。

我回答:没有这个必要性。我们当前使用 vue3 来开发 H5。vue 的框架就非常好能改造成双线程的架构。比如下面这张图

image.png

开发者开发的 Vue 对象,我们能够很轻松把代码拆解成渲染和逻辑两块。渲染和 Vue 的渲染模块(如 diff, vm 管理的模块)扔给 webview。逻辑代码和管理模块扔到 JSCore 里就可以了。

小狐狸恍然大悟,思考了一会又绕绕头。记下来呢怎么更新,不会每个按钮都映射一个函数事件吧。我拍拍她的脑袋,随即把图片的右边数据通讯补齐了。

暂时无法在飞书文档外展示此内容

image.png

事实上,webview 只用发送当前用户操作了什么。JScore 只用当 data 对象更新了,告诉 webview data 有什么即可。执行操作对应的函数和 data 改变触发的视图更新都在渲染和视图内部完成。

当然还会遇到很多细节问题,比如我们如何管理这么多 vue component 并更新,如何解决 querySelector 的问题。我们可以之后再聊。

❌