跳转到内容

基础示例

以下 TypeScript 代码块可独立复制。存储和 DB 示例需要浏览器环境;HTTP 示例需要业务接口,Bridge 和 Logger 示例使用内存适配以便本地验证。

保存用户选择

ts
import { createStorage } from '@freenotes/web-runtime';

const storage = createStorage('local');

storage.set('scope', {
  subjectId: 'english',
  courseId: 'cet4'
});

const scope = storage.get('scope');

类型化 key-value

ts
import { createStorage } from '@freenotes/web-runtime';

interface AppStorageSchema {
  scope: {
    courseId: string;
    subjectId: string;
  };
}

const storage = createStorage<AppStorageSchema>('local');

storage.set('scope', {
  courseId: 'cet4',
  subjectId: 'english'
});

const typedScope = storage.get('scope');

typedScope?.courseId;

保存会话状态

ts
import { createStorage } from '@freenotes/web-runtime';

const sessionStorage = createStorage('session');

sessionStorage.set('last-path', location.pathname);

保存带过期时间的数据

ts
import { createStorage } from '@freenotes/web-runtime';

const storage = createStorage('local');

storage.set('lease', { id: 'startup-ad' }, { ttlMs: 30 * 1000 });

const lease = storage.get('lease');

使用 IndexedDB

ts
import { createDb } from '@freenotes/web-runtime';

const db = await createDb().open({
  name: 'question-bank',
  stores: {
    books: {
      keyPath: 'id'
    }
  }
});

try {
  await db.store('books').put({
    id: 'book-1',
    title: 'English'
  });
  const book = await db.store('books').get('book-1');
  console.info(book);
} finally {
  db.close();
}

复制文本

ts
import { ClipboardError, createClipboard } from '@freenotes/web-runtime';

// 将 copyShareLink 绑定到复制按钮的点击事件。
export async function copyShareLink(): Promise<string> {
  try {
    await createClipboard().writeText('https://example.com/share');
    return '已复制';
  } catch (error) {
    if (error instanceof ClipboardError) return '复制失败,请稍后重试';
    throw error;
  }
}

调用方根据返回文案展示提示。复制通常需要安全上下文和用户交互,具体限制见 Clipboard 指南

声明 HTTP 接口

假设 GET /api/books/:id 返回 { code: 0, data: { id, title } },显式开启业务解包。这里用 parser 验证解包后的外部数据,contract.type() 本身只声明类型。

ts
import { createHttp, HttpError, unwrapBusinessEnvelope } from '@freenotes/web-runtime';

interface Book {
  id: string;
  title: string;
}

const http = createHttp({
  baseURL: '/api',
  decodeResponse: unwrapBusinessEnvelope
});

const api = http.createApi((contract) => ({
  getBook: {
    method: 'get',
    url: '/books/:id',
    path: contract.type<{ id: string }>(),
    response: contract.type<unknown>()
  }
}));

function parseBook(value: unknown): Book {
  if (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    typeof value.id === 'string' &&
    'title' in value &&
    typeof value.title === 'string'
  ) {
    return { id: value.id, title: value.title };
  }
  throw new TypeError('Invalid book response');
}

export async function loadBook(id: string): Promise<Book> {
  try {
    return parseBook(await api.getBook({ path: { id } }));
  } catch (error) {
    if (error instanceof HttpError) {
      console.error(error.kind, error.businessCode, error.transportCode);
    }
    throw error;
  }
}

如果服务端直接返回业务对象,省略 decodeResponse。需要 Standard Schema 校验、拦截器或重试时,继续阅读 HTTP 指南

浏览器中模拟 Bridge

mock 返回已解码数据,并提供真实的订阅清理函数。示例结束后不会保留事件监听。

ts
import {
  createBridge,
  type BridgeAdapter,
  type BridgeHandler
} from '@freenotes/web-runtime';

interface Methods {
  openDocument: { input: { documentId: string }; output: void };
}
interface Events {
  resume: { source: string };
}

const handlers = new Map<string, Set<BridgeHandler>>();
const adapter: BridgeAdapter = {
  async call(method, payload) {
    if (method === 'configAction') {
      return { app_version: 'mock-1.0', platform: 'browser' };
    }
    if (method === 'openDocument') {
      console.info('模拟打开文档', payload);
      return undefined;
    }
    throw new Error(`Unsupported mock method: ${method}`);
  },
  on(event, handler) {
    const listeners = handlers.get(event) ?? new Set<BridgeHandler>();
    handlers.set(event, listeners);
    listeners.add(handler);
    return () => {
      listeners.delete(handler);
      if (listeners.size === 0) handlers.delete(event);
    };
  }
};

const bridge = createBridge<Methods, Events>({ adapter });
const subscription = bridge.on('resume', (payload) => {
  console.info('页面恢复来源', payload.source);
});
try {
  const config = await bridge.getConfig();
  console.info(config?.appVersion); // mock-1.0
  await bridge.call('openDocument', { documentId: 'doc-1' });
  const event: Events['resume'] = { source: 'mock' };
  for (const handler of handlers.get('resume') ?? []) handler(event);
} finally {
  subscription.dispose();
}

App 内使用默认适配时省略 adapter;仍需宿主实现对应方法和事件。协议边界见 Bridge 进阶

使用自定义 Logger transport

用内存数组观察实际日志记录。生产接入时,把 transport 替换为已有日志 SDK 或上报服务;transport 失败应抛错或 reject。

ts
import { createLogger, type LoggerRecord } from '@freenotes/web-runtime';

interface Context {
  documentId: string;
}

const records: LoggerRecord<Context>[] = [];
const logger = createLogger<Context>({
  source: 'document-page',
  transport(record) {
    records.push(record);
  }
});

const result = await logger.info('document_opened', { documentId: 'doc-1' });
if (result.status === 'dispatched') {
  console.info(records[0]?.context?.documentId); // doc-1
} else if (result.status === 'skipped') {
  console.info(result.reason);
} else {
  console.error(result.error);
}

await logger.error('document_failed', {
  error: new Error('Document unavailable'),
  context: { documentId: 'doc-1' }
});

dispatched 表示 transport 调用完成。需要 App 日志协议时使用 createBridgeLogTransport(),详见 Logger 指南

让基础能力保持简单,让业务开发更加专注。